From b2b71198caefacae4f08f880384c0d213350008f Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 8 Jun 2026 00:12:53 +0300 Subject: [PATCH] fix: persist agent permission edits Prevents permission changes from being overwritten by other agent fields Writes built-in and custom agent permissions to the correct config source Refreshes agent state after permission saves --- .../components/sections/agents/AgentsPage.tsx | 20 ++- packages/ui/src/stores/useAgentsStore.ts | 16 ++ packages/vscode/src/opencodeConfig.ts | 146 +++++++++++++++++- packages/web/server/lib/opencode/agents.js | 54 ++++--- 4 files changed, 207 insertions(+), 29 deletions(-) diff --git a/packages/ui/src/components/sections/agents/AgentsPage.tsx b/packages/ui/src/components/sections/agents/AgentsPage.tsx index 99c55b48..5d7b182a 100644 --- a/packages/ui/src/components/sections/agents/AgentsPage.tsx +++ b/packages/ui/src/components/sections/agents/AgentsPage.tsx @@ -122,6 +122,10 @@ const filterRulesAgainstGlobal = (ruleset: PermissionRule[], globalAction: Permi ); const permissionConfigToRuleset = (value: unknown): PermissionRule[] => { + if (Array.isArray(value)) { + return normalizeRuleset(value as PermissionRule[]); + } + if (isPermissionAction(value)) { return [{ permission: '*', pattern: '*', action: value }]; } @@ -162,9 +166,7 @@ const buildPermissionConfigWithGlobal = ( (grouped[rule.permission] ||= {})[rule.pattern] = rule.action; } - const result: Record = { - '*': globalAction, - }; + const result: Record = {}; for (const [permissionName, patterns] of Object.entries(grouped)) { if (permissionName === '*') { @@ -179,6 +181,14 @@ const buildPermissionConfigWithGlobal = ( result[permissionName] = patterns; } + if (Object.keys(result).length === 0) { + return globalAction; + } + + if (globalAction !== 'allow') { + result['*'] = globalAction; + } + return result as AgentConfig['permission']; }; @@ -273,7 +283,7 @@ export const AgentsPage: React.FC = () => { const names = new Set(); for (const agent of agents) { - const rules = normalizeRuleset(Array.isArray(agent.permission) ? agent.permission as PermissionRule[] : []); + const rules = normalizeRuleset(permissionConfigToRuleset(agent.permission)); for (const rule of rules) { if (rule.permission && rule.permission !== '*' && rule.permission !== 'invalid') { names.add(rule.permission); @@ -509,7 +519,7 @@ export const AgentsPage: React.FC = () => { setPrompt(promptValue); const permissionState = applyPermissionState( - Array.isArray(selectedAgent.permission) ? selectedAgent.permission as PermissionRule[] : [], + permissionConfigToRuleset(selectedAgent.permission), ); initialStateRef.current = { diff --git a/packages/ui/src/stores/useAgentsStore.ts b/packages/ui/src/stores/useAgentsStore.ts index 2e07aebd..0f4fe455 100644 --- a/packages/ui/src/stores/useAgentsStore.ts +++ b/packages/ui/src/stores/useAgentsStore.ts @@ -70,12 +70,24 @@ const getAgentsCacheKey = (directory: string | null): string => { return directory?.trim() || DEFAULT_AGENTS_CACHE_KEY; }; +const invalidateAgentsLoadCache = (directory: string | null = getConfigDirectory()) => { + agentsLastLoadedAt.delete(getAgentsCacheKey(directory)); +}; + const buildAgentsSignature = (agents: Agent[]): string => { return agents .map((agent) => { const extended = agent as AgentWithExtras; return [ agent.name, + extended.mode ?? '', + typeof extended.model === 'object' && extended.model + ? `${extended.model.providerID ?? ''}/${extended.model.modelID ?? ''}` + : String(extended.model ?? ''), + String(extended.temperature ?? ''), + String((extended as { topP?: unknown; top_p?: unknown }).topP ?? (extended as { topP?: unknown; top_p?: unknown }).top_p ?? ''), + extended.prompt ?? '', + JSON.stringify(extended.permission ?? null), extended.scope ?? '', extended.group ?? '', extended.description ?? '', @@ -345,6 +357,7 @@ export const useAgentsStore = create()( } const needsReload = payload?.requiresReload ?? true; + invalidateAgentsLoadCache(configDirectory); if (needsReload) { requiresReload = true; await refreshAfterOpenCodeRestart({ @@ -406,6 +419,7 @@ export const useAgentsStore = create()( } const needsReload = payload?.requiresReload ?? true; + invalidateAgentsLoadCache(configDirectory); if (needsReload) { requiresReload = true; await refreshAfterOpenCodeRestart({ @@ -452,6 +466,7 @@ export const useAgentsStore = create()( } const needsReload = payload?.requiresReload ?? true; + invalidateAgentsLoadCache(configDirectory); if (needsReload) { requiresReload = true; await refreshAfterOpenCodeRestart({ @@ -629,6 +644,7 @@ async function performConfigRefresh(options: { const uiRefreshTasks: Promise[] = []; if (refreshAgentConfigs) { + invalidateAgentsLoadCache(currentDirectory); uiRefreshTasks.push(agentConfigStore.loadAgents().then(() => undefined)); } if (refreshCommands) { diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 1e59ca81..80c79a4b 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -1431,6 +1431,103 @@ const getJsonWriteTarget = ( return { config: userConfig, path: paths.userPath }; }; +const getAgentPermissionSource = (agentName: string, workingDirectory?: string) => { + if (workingDirectory) { + const projectMdPath = getProjectAgentPath(workingDirectory, agentName); + if (fs.existsSync(projectMdPath)) { + const { frontmatter } = parseMdFile(projectMdPath); + if (frontmatter.permission !== undefined) { + return { source: 'md' as const, scope: AGENT_SCOPE.PROJECT, path: projectMdPath }; + } + } + } + + const userMdPath = getUserAgentPath(agentName); + if (fs.existsSync(userMdPath)) { + const { frontmatter } = parseMdFile(userMdPath); + if (frontmatter.permission !== undefined) { + return { source: 'md' as const, scope: AGENT_SCOPE.USER, path: userMdPath }; + } + } + + const layers = readConfigLayers(workingDirectory); + const customAgent = ((layers.customConfig as Record)?.agent as Record | undefined)?.[agentName] as Record | undefined; + if (customAgent?.permission !== undefined && layers.paths.customPath) { + return { source: 'json' as const, scope: 'custom' as const, path: layers.paths.customPath }; + } + + const projectAgent = ((layers.projectConfig as Record)?.agent as Record | undefined)?.[agentName] as Record | undefined; + if (projectAgent?.permission !== undefined && layers.paths.projectPath) { + return { source: 'json' as const, scope: AGENT_SCOPE.PROJECT, path: layers.paths.projectPath }; + } + + const userAgent = ((layers.userConfig as Record)?.agent as Record | undefined)?.[agentName] as Record | undefined; + if (userAgent?.permission !== undefined) { + return { source: 'json' as const, scope: AGENT_SCOPE.USER, path: layers.paths.userPath }; + } + + return { source: null, scope: null, path: null }; +}; + +const mergePermissionWithNonWildcards = (newPermission: unknown, permissionSource: ReturnType, agentName: string) => { + if (!permissionSource.source || !permissionSource.path) { + return newPermission; + } + + let existingPermission: unknown = null; + if (permissionSource.source === 'md') { + const { frontmatter } = parseMdFile(permissionSource.path); + existingPermission = frontmatter.permission; + } else if (permissionSource.source === 'json') { + const config = readConfigFile(permissionSource.path) as Record; + existingPermission = (((config.agent as Record | undefined)?.[agentName] as Record | undefined)?.permission); + } + + if (!existingPermission || typeof existingPermission === 'string' || newPermission == null || typeof newPermission === 'string') { + return newPermission; + } + + if (typeof existingPermission !== 'object' || Array.isArray(existingPermission) || typeof newPermission !== 'object' || Array.isArray(newPermission)) { + return newPermission; + } + + const nonWildcardPatterns: Record> = {}; + for (const [permKey, permValue] of Object.entries(existingPermission as Record)) { + if (permKey === '*' || typeof permValue !== 'object' || permValue === null || Array.isArray(permValue)) continue; + const nonWildcards: Record = {}; + for (const [pattern, action] of Object.entries(permValue as Record)) { + if (pattern !== '*') { + nonWildcards[pattern] = action; + } + } + if (Object.keys(nonWildcards).length > 0) { + nonWildcardPatterns[permKey] = nonWildcards; + } + } + + if (Object.keys(nonWildcardPatterns).length === 0) { + return newPermission; + } + + const merged: Record = { ...(newPermission as Record) }; + for (const [permKey, patterns] of Object.entries(nonWildcardPatterns)) { + const newValue = merged[permKey]; + if (typeof newValue === 'string') { + merged[permKey] = { '*': newValue, ...patterns }; + } else if (typeof newValue === 'object' && newValue !== null && !Array.isArray(newValue)) { + merged[permKey] = { ...patterns, ...(newValue as Record) }; + } else { + const existingValue = (existingPermission as Record)[permKey]; + if (typeof existingValue === 'object' && existingValue !== null && !Array.isArray(existingValue)) { + const wildcard = (existingValue as Record)['*']; + merged[permKey] = wildcard ? { '*': wildcard, ...patterns } : patterns; + } + } + } + + return merged; +}; + const parseMdFile = (filePath: string): { frontmatter: Record; body: string } => { const content = fs.readFileSync(filePath, 'utf8'); const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); @@ -1545,7 +1642,7 @@ export const updateAgent = (agentName: string, updates: Record, const hasJsonFields = Boolean(jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0); const jsonTarget = jsonSource.exists ? { config: jsonSource.config, path: jsonSource.path } - : getJsonWriteTarget(layers, workingDirectory ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER); + : getJsonWriteTarget(layers, AGENT_SCOPE.USER); const config = (jsonTarget.config || {}) as Record; // Determine if we should create a new md file: @@ -1594,6 +1691,53 @@ export const updateAgent = (agentName: string, updates: Record, continue; } + if (field === 'permission') { + const permissionSource = getAgentPermissionSource(agentName, workingDirectory); + const newPermission = mergePermissionWithNonWildcards(value, permissionSource, agentName); + + if (permissionSource.source === 'md' && permissionSource.path) { + if (mdData && permissionSource.path === targetPath) { + mdData.frontmatter.permission = newPermission; + mdModified = true; + } else { + const existingMdData = parseMdFile(permissionSource.path); + existingMdData.frontmatter.permission = newPermission; + writeMdFile(permissionSource.path, existingMdData.frontmatter, existingMdData.body); + } + } else if (permissionSource.source === 'json' && permissionSource.path) { + if (permissionSource.path === (jsonTarget.path || CONFIG_FILE)) { + if (!config.agent) config.agent = {}; + const current = ((config.agent as Record)[agentName] as Record | undefined) ?? {}; + (config.agent as Record)[agentName] = { ...current, permission: newPermission }; + jsonModified = true; + } else { + const existingConfig = readConfigFile(permissionSource.path) as Record; + const agentMap = (existingConfig.agent as Record | undefined) ?? {}; + const current = (agentMap[agentName] as Record | undefined) ?? {}; + agentMap[agentName] = { ...current, permission: newPermission }; + existingConfig.agent = agentMap; + writeConfig(existingConfig, permissionSource.path); + } + } else if (mdExists && mdData) { + mdData.frontmatter.permission = newPermission; + mdModified = true; + } else if (hasJsonFields) { + if (!config.agent) config.agent = {}; + const current = ((config.agent as Record)[agentName] as Record | undefined) ?? {}; + (config.agent as Record)[agentName] = { ...current, permission: newPermission }; + jsonModified = true; + } else { + const writeTarget = getJsonWriteTarget(layers, AGENT_SCOPE.USER); + const targetConfig = (writeTarget.config || {}) as Record; + const agentMap = (targetConfig.agent as Record | undefined) ?? {}; + const current = (agentMap[agentName] as Record | undefined) ?? {}; + agentMap[agentName] = { ...current, permission: newPermission }; + targetConfig.agent = agentMap; + writeConfig(targetConfig, writeTarget.path || CONFIG_FILE); + } + continue; + } + const hasMdField = Boolean(mdData?.frontmatter?.[field] !== undefined); const hasJsonField = Boolean(jsonSection?.[field] !== undefined); diff --git a/packages/web/server/lib/opencode/agents.js b/packages/web/server/lib/opencode/agents.js index 2db6995d..06932df0 100644 --- a/packages/web/server/lib/opencode/agents.js +++ b/packages/web/server/lib/opencode/agents.js @@ -193,27 +193,25 @@ function getAgentPermissionSource(agentName, workingDirectory, lookupCache = nul } } - // Check JSON layers (project > user) + // Check JSON layers in effective override order. readConfigLayers merges + // user -> project -> custom, so custom wins over project, project over user. const layers = readConfigLayers(workingDirectory); - // Project opencode.json + const customJsonPermission = layers.customConfig?.agent?.[agentName]?.permission; + if (customJsonPermission !== undefined && layers.paths.customPath) { + return { source: 'json', scope: 'custom', path: layers.paths.customPath }; + } + const projectJsonPermission = layers.projectConfig?.agent?.[agentName]?.permission; if (projectJsonPermission !== undefined && layers.paths.projectPath) { return { source: 'json', scope: AGENT_SCOPE.PROJECT, path: layers.paths.projectPath }; } - // User opencode.json const userJsonPermission = layers.userConfig?.agent?.[agentName]?.permission; if (userJsonPermission !== undefined) { return { source: 'json', scope: AGENT_SCOPE.USER, path: layers.paths.userPath }; } - // Custom config (env var) - const customJsonPermission = layers.customConfig?.agent?.[agentName]?.permission; - if (customJsonPermission !== undefined && layers.paths.customPath) { - return { source: 'json', scope: 'custom', path: layers.paths.customPath }; - } - return { source: null, scope: null, path: null }; } @@ -486,19 +484,31 @@ function updateAgent(agentName, updates, workingDirectory) { const newPermission = mergePermissionWithNonWildcards(value, permissionSource, agentName); if (permissionSource.source === 'md') { - const existingMdData = parseMdFile(permissionSource.path); - existingMdData.frontmatter.permission = newPermission; - writeMdFile(permissionSource.path, existingMdData.frontmatter, existingMdData.body); - console.log(`Updated permission in .md file: ${permissionSource.path}`); + if (mdData && permissionSource.path === targetPath) { + mdData.frontmatter.permission = newPermission; + mdModified = true; + } else { + const existingMdData = parseMdFile(permissionSource.path); + existingMdData.frontmatter.permission = newPermission; + writeMdFile(permissionSource.path, existingMdData.frontmatter, existingMdData.body); + console.log(`Updated permission in .md file: ${permissionSource.path}`); + } } else if (permissionSource.source === 'json') { - const existingConfig = readConfigFile(permissionSource.path); - if (!existingConfig.agent) existingConfig.agent = {}; - if (!existingConfig.agent[agentName]) existingConfig.agent[agentName] = {}; - existingConfig.agent[agentName].permission = newPermission; - writeConfig(existingConfig, permissionSource.path); - console.log(`Updated permission in JSON: ${permissionSource.path}`); + if (permissionSource.path === (jsonTarget.path || CONFIG_FILE)) { + if (!config.agent) config.agent = {}; + if (!config.agent[agentName]) config.agent[agentName] = {}; + config.agent[agentName].permission = newPermission; + jsonModified = true; + } else { + const existingConfig = readConfigFile(permissionSource.path); + if (!existingConfig.agent) existingConfig.agent = {}; + if (!existingConfig.agent[agentName]) existingConfig.agent[agentName] = {}; + existingConfig.agent[agentName].permission = newPermission; + writeConfig(existingConfig, permissionSource.path); + console.log(`Updated permission in JSON: ${permissionSource.path}`); + } } else { - if ((mdExists || creatingNewMd) && mdData) { + if (mdExists && mdData) { mdData.frontmatter.permission = newPermission; mdModified = true; } else if (hasJsonFields) { @@ -507,9 +517,7 @@ function updateAgent(agentName, updates, workingDirectory) { config.agent[agentName].permission = newPermission; jsonModified = true; } else { - const writeTarget = workingDirectory - ? { config: layers.projectConfig || {}, path: layers.paths.projectPath || layers.paths.userPath } - : { config: layers.userConfig || {}, path: layers.paths.userPath }; + const writeTarget = getJsonWriteTarget(layers, AGENT_SCOPE.USER); if (!writeTarget.config.agent) writeTarget.config.agent = {}; if (!writeTarget.config.agent[agentName]) writeTarget.config.agent[agentName] = {}; writeTarget.config.agent[agentName].permission = newPermission;