diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts index 0d888cd3..f50543c7 100644 --- a/server/modules/providers/services/provider-capabilities.service.ts +++ b/server/modules/providers/services/provider-capabilities.service.ts @@ -74,7 +74,10 @@ const PROVIDER_CAPABILITIES: Record = { }, opencode: { provider: 'opencode', - permissionModes: ['default'], + // Mapped by the runtime onto OpenCode's controls: `--agent plan` (plan), + // `--auto` (bypassPermissions) and the OPENCODE_PERMISSION env var + // (acceptEdits). See resolveOpenCodePermissionOptions in opencode-cli.js. + permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], defaultPermissionMode: 'default', supportsImages: true, supportsAbort: true, diff --git a/server/opencode-cli.js b/server/opencode-cli.js index eadd8efd..c165e48e 100644 --- a/server/opencode-cli.js +++ b/server/opencode-cli.js @@ -15,6 +15,36 @@ const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; const activeOpenCodeProcesses = new Map(); +/** + * Maps the UI permission mode onto OpenCode's non-interactive controls. + * + * OpenCode has no single "permission mode" flag; each mode uses a different + * lever of the `opencode run` CLI (verified against v1.17.13): + * - plan → the built-in read-only `plan` agent (`--agent plan`). + * - bypassPermissions → `--auto`, which auto-approves every permission that + * is not explicitly denied in the user's config. + * - acceptEdits → the OPENCODE_PERMISSION env var, whose JSON body the + * CLI merges into its permission config. Forcing + * `edit: allow` guarantees file edits go through while + * every other rule stays under the user's own config. + * - default → nothing; the user's opencode.json governs. In + * non-interactive `run` mode any `ask` rule is denied. + * + * Exported for tests only. + */ +export function resolveOpenCodePermissionOptions(permissionMode) { + switch (permissionMode) { + case 'plan': + return { args: ['--agent', 'plan'], env: {} }; + case 'bypassPermissions': + return { args: ['--auto'], env: {} }; + case 'acceptEdits': + return { args: [], env: { OPENCODE_PERMISSION: JSON.stringify({ edit: 'allow' }) } }; + default: + return { args: [], env: {} }; + } +} + function resolveOpenCodeEffort(model, effort, modelsDefinition) { const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model); const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || []; @@ -93,7 +123,7 @@ function readOpenCodeTokenUsage(sessionId) { async function spawnOpenCode(command, options = {}, ws) { return new Promise((resolve, reject) => { - const { sessionId, projectPath, cwd, model, effort, sessionSummary, images } = options; + const { sessionId, projectPath, cwd, model, effort, sessionSummary, images, permissionMode } = options; const workingDir = cwd || projectPath || process.cwd(); const processKey = sessionId || Date.now().toString(); let capturedSessionId = sessionId || null; @@ -224,6 +254,8 @@ async function spawnOpenCode(command, options = {}, ws) { if (resolvedEffort) { args.push('--variant', resolvedEffort); } + const permissionOptions = resolveOpenCodePermissionOptions(permissionMode); + args.push(...permissionOptions.args); if (command && command.trim()) { // Image attachments ride along as an path list appended // to the prompt; the session history reader strips the tag back out. @@ -235,7 +267,7 @@ async function spawnOpenCode(command, options = {}, ws) { opencodeProcess = spawnFunction('opencode', args, { cwd: workingDir, stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env }, + env: { ...process.env, ...permissionOptions.env }, }); activeOpenCodeProcesses.set(processKey, opencodeProcess); diff --git a/server/opencode-cli.test.js b/server/opencode-cli.test.js index acac202c..f7973e3e 100644 --- a/server/opencode-cli.test.js +++ b/server/opencode-cli.test.js @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import { spawnOpenCode } from './opencode-cli.js'; +import { resolveOpenCodePermissionOptions, spawnOpenCode } from './opencode-cli.js'; const findEnvKey = (name) => Object.keys(process.env).find((key) => key.toLowerCase() === name.toLowerCase()) || name; @@ -14,7 +14,10 @@ async function createFakeOpenCodeExecutable(binDir) { await writeFile(scriptPath, ` const capturePath = process.env.OPENCODE_ARGS_CAPTURE; if (capturePath) { - require('node:fs').writeFileSync(capturePath, JSON.stringify(process.argv.slice(2))); + require('node:fs').writeFileSync(capturePath, JSON.stringify({ + args: process.argv.slice(2), + permissionEnv: process.env.OPENCODE_PERMISSION ?? null, + })); } const events = [ @@ -86,10 +89,116 @@ test('spawnOpenCode emits session_created before normalized live messages for ne assert.equal(complete?.sessionId, 'open-live-1'); assert.equal(messages.some((message) => message.kind === 'error'), false); - const launchedArgs = JSON.parse(await readFile(argsCapturePath, 'utf8')); + const capture = JSON.parse(await readFile(argsCapturePath, 'utf8')); + const launchedArgs = capture.args; assert.ok(Array.isArray(launchedArgs)); assert.deepEqual(launchedArgs.slice(0, 4), ['run', '--format', 'json', '--dir']); assert.equal(launchedArgs[4], tempRoot); + // No permission mode requested → no permission flags and no env override. + assert.equal(launchedArgs.includes('--auto'), false); + assert.equal(launchedArgs.includes('--agent'), false); + assert.equal(capture.permissionEnv, null); + } finally { + if (previousPath === undefined) { + delete process.env[pathKey]; + } else { + process.env[pathKey] = previousPath; + } + + if (previousPathExt === undefined) { + delete process.env[pathExtKey]; + } else { + process.env[pathExtKey] = previousPathExt; + } + + if (previousArgsCapture === undefined) { + delete process.env.OPENCODE_ARGS_CAPTURE; + } else { + process.env.OPENCODE_ARGS_CAPTURE = previousArgsCapture; + } + + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('resolveOpenCodePermissionOptions maps UI permission modes onto OpenCode controls', () => { + assert.deepEqual(resolveOpenCodePermissionOptions('plan'), { + args: ['--agent', 'plan'], + env: {}, + }); + assert.deepEqual(resolveOpenCodePermissionOptions('bypassPermissions'), { + args: ['--auto'], + env: {}, + }); + assert.deepEqual(resolveOpenCodePermissionOptions('acceptEdits'), { + args: [], + env: { OPENCODE_PERMISSION: '{"edit":"allow"}' }, + }); + // default and anything unknown leave the user's own opencode config in charge. + assert.deepEqual(resolveOpenCodePermissionOptions('default'), { args: [], env: {} }); + assert.deepEqual(resolveOpenCodePermissionOptions(undefined), { args: [], env: {} }); +}); + +test('spawnOpenCode passes permission mode flags and env to the CLI', async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-perms-')); + const pathKey = findEnvKey('PATH'); + const pathExtKey = findEnvKey('PATHEXT'); + const previousPath = process.env[pathKey]; + const previousPathExt = process.env[pathExtKey]; + const previousArgsCapture = process.env.OPENCODE_ARGS_CAPTURE; + const writer = { + userId: null, + sessionId: null, + send() {}, + setSessionId(sessionId) { + this.sessionId = sessionId; + }, + }; + + try { + await createFakeOpenCodeExecutable(tempRoot); + process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`; + if (process.platform === 'win32') { + process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD') + ? previousPathExt + : `.COM;.EXE;.BAT;.CMD${previousPathExt ? `;${previousPathExt}` : ''}`; + } + + const scenarios = [ + { + permissionMode: 'plan', + expectArgs: ['--agent', 'plan'], + expectPermissionEnv: null, + }, + { + permissionMode: 'bypassPermissions', + expectArgs: ['--auto'], + expectPermissionEnv: null, + }, + { + permissionMode: 'acceptEdits', + expectArgs: [], + expectPermissionEnv: '{"edit":"allow"}', + }, + ]; + + for (const scenario of scenarios) { + const argsCapturePath = path.join(tempRoot, `opencode-args-${scenario.permissionMode}.json`); + process.env.OPENCODE_ARGS_CAPTURE = argsCapturePath; + + await spawnOpenCode('Hi', { cwd: tempRoot, permissionMode: scenario.permissionMode }, writer); + + const capture = JSON.parse(await readFile(argsCapturePath, 'utf8')); + for (const expectedArg of scenario.expectArgs) { + assert.ok( + capture.args.includes(expectedArg), + `${scenario.permissionMode}: expected "${expectedArg}" in ${JSON.stringify(capture.args)}`, + ); + } + // The prompt stays the last positional argument, after any permission flags. + assert.equal(capture.args[capture.args.length - 1], 'Hi'); + assert.equal(capture.permissionEnv, scenario.expectPermissionEnv); + } } finally { if (previousPath === undefined) { delete process.env[pathKey]; diff --git a/server/routes/agent.js b/server/routes/agent.js index 5febbf61..8207d5c0 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -1005,7 +1005,8 @@ router.post('/', validateExternalApiKey, async (req, res) => { cwd: finalProjectPath, sessionId: sessionId || null, model: model || opencodeModels.DEFAULT, - effort + effort, + permissionMode: 'bypassPermissions' // Agent runs are non-interactive, like the other providers above }, writer); } diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index 86a0eced..17a848b0 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -37,7 +37,7 @@ const FALLBACK_PERMISSION_MODES: Record = { cursor: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], codex: ['default', 'acceptEdits', 'bypassPermissions'], gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], - opencode: ['default'], + opencode: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], }; type ProviderCapabilities = {