From 19833098aa6266cd8d44bd4870c8682bffff858c Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Fri, 4 Sep 2026 16:57:04 +0000 Subject: [PATCH 1/3] feat: give OpenCode an attachable Stage 3 pr-reviewer recipe so its agent card exposes a Shell session (#6238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Stage 3 ("PR Code Review & Actions") run on an OpenCode-backed TUI provider was always forced headless: the OPENCODE vendor row declared no sandboxed-actions recipe, so supportsTuiPublicReviewActionsProvider answered false and the agent card showed "No shell". The headless argv is a one-shot `opencode run …`, which cannot become an interactive session by dropping flags the way Claude's recipe does. Add a sandboxed-actions recipe to the OPENCODE row. Headless runs keep today's `run`-prefixed argv byte-for-byte; the attachable (`tui: true`) shape is the bare binary — OpenCode's real TUI entry point — with `--agent build` pinned and `-m` namespaced by the same prefixOpencodeModel call, and the spawner pastes the prompt as it does for every other TUI. Verified under node-pty against a local Ollama backend: the pasted prompt renders, Enter submits it, and the build agent completes a tool call. The recipe matches any spawnable OpenCode binary (MTPLX and gateway backends included), unlike the Ollama-only no-tool gate. The recipe adds no sandbox — the headless run already had full tool access with the disposable worktree as isolation — so it carries `enforces: false` and the schedule UI keeps reporting the choice as worktree-only rather than OS-sandboxed. --- server/lib/providerVendors.js | 85 +++++++++++++++++-- .../lib/providerVendors.publicReview.test.js | 58 ++++++++++++- .../agentLifecycle.postureGate.test.js | 33 +++++++ 3 files changed, 164 insertions(+), 12 deletions(-) diff --git a/server/lib/providerVendors.js b/server/lib/providerVendors.js index ce93d1a46d..5ae932540b 100644 --- a/server/lib/providerVendors.js +++ b/server/lib/providerVendors.js @@ -392,6 +392,57 @@ function opencodePublicReviewSpawnArgs(provider, { effectiveModel } = {}) { }; } +const opencodeHeadlessSpawnArgs = defaultSpawnArgs(opencodeCliArgs, 'opencode'); + +/** + * The tool-enabled agent an attachable Stage 3 session runs as. Named on the + * argv rather than left to OpenCode's default so the session cannot inherit a + * `plan` (read-only, hand-back-to-a-human) default from a user's own config. + */ +const OPENCODE_PUBLIC_REVIEW_ACTIONS_AGENT = 'build'; + +/** + * The `sandboxed-actions` recipe. OpenCode ships no OS sandbox of its own, so + * this adds NO enforcement over the open-to-every-binary tier (the disposable + * worktree, the stripped child env, and the deterministic coordinator are the + * isolation — see `supportsPublicReviewPosture`). The row exists for the + * ATTACHABLE case (#6238): a headless run is still today's `opencode run …`, + * argv-for-argv, but that one-shot print-mode invocation cannot become an + * interactive session by dropping flags the way Claude's recipe does — in a + * PTY it neither accepts a pasted prompt nor renders anything. OpenCode's real + * interactive entry point is the BARE binary (`opencode [project]`, its default + * subcommand), which takes the same `--agent`/`-m` flags and reads the prompt + * the spawner already pastes into every other TUI (`agentTuiSpawning.js`). + * Verified under node-pty against a local Ollama backend: the pasted prompt + * renders, Enter submits it, and the build agent answers through its tools. + * + * `-m` is namespaced by `prefixOpencodeModel` exactly as the headless argv is, + * so the two shapes cannot drift on the model. Provider args are not forwarded + * on the attachable path (same rule as `buildTuiSpawnConfig`): a saved + * `--agent plan` would swap the tool-enabled agent for one that hands back to a + * human nobody has to be, and `--auto` is redundant with the config's blanket + * allow. The permission surface is the same on both paths: the config + * `buildOpencodeEnvVars` composes allows every tool and denies the three + * interactive gates (`question` / `plan_enter` / `plan_exit`), so an unattended + * session never parks on a selector — while an operator who attaches sees the + * same run the headless child would have executed. + */ +function opencodePublicReviewActionsSpawnArgs(provider, { effectiveModel, effort, tui = false } = {}) { + if (!tui) return opencodeHeadlessSpawnArgs(provider, { effectiveModel, effort }); + const args = ['--agent', OPENCODE_PUBLIC_REVIEW_ACTIONS_AGENT]; + const resolvedModel = prefixOpencodeModel(provider, effectiveModel); + if (resolvedModel) args.push('-m', resolvedModel); + return { command: provider?.command || 'opencode', args, stdinMode: 'prompt' }; +} + +/** + * Any spawnable OpenCode binary, whatever it fronts. Unlike the no-tool + * matcher there is no model-capability probe involved, so an MTPLX / llama.cpp + * / vLLM / gateway backend is as eligible as Ollama — the actions stage is open + * to every binary provider anyway; this row only decides attachability. + */ +const matchOpencodeBinary = (provider) => isDirectBinaryProvider(provider) && isOpencodeCommand(provider?.command); + const OPENCODE = { id: 'opencode', idFragment: 'opencode', @@ -401,15 +452,23 @@ const OPENCODE = { // (buildVendorCliArgs/buildVendorSpawnConfig fall back to matchCommand when // matchCliProvider is absent). cliArgs: opencodeCliArgs, - spawnArgs: defaultSpawnArgs(opencodeCliArgs, 'opencode'), + spawnArgs: opencodeHeadlessSpawnArgs, publicReview: { [PUBLIC_REVIEW_NO_TOOL_POSTURE]: { spawnArgs: opencodePublicReviewSpawnArgs, matchProvider: isLocalOpencodeProvider, }, - // No `sandboxed-actions` recipe: OpenCode ships no OS sandbox of its own, - // so it stays in the open-to-every-binary tier where the disposable - // worktree is the isolation — see `supportsPublicReviewPosture`. + [PUBLIC_REVIEW_ACTIONS_POSTURE]: { + spawnArgs: opencodePublicReviewActionsSpawnArgs, + matchProvider: matchOpencodeBinary, + // Not an enforcement: the stage stays worktree-only in the schedule UI. + enforces: false, + // Attachable: the bare-binary shape above is what the PTY runs. There is + // no argv-level permission boundary to preserve here — the headless run + // already executes with the same full tool access — so attaching widens + // nothing; the recipe only supplies the invocation a PTY can drive. + tui: true, + }, }, }; @@ -676,7 +735,8 @@ const CLAUDE = { [PUBLIC_REVIEW_ACTIONS_POSTURE]: { spawnArgs: claudePublicReviewSpawnArgsFor(CLAUDE_PUBLIC_REVIEW_ACTIONS_ARGS), matchProvider: matchClaudeBinary, - // The only posture/vendor pairing that may run as an ATTACHABLE session. + // One of two posture/vendor pairings that may run as an ATTACHABLE session + // (OpenCode is the other — see its row). // `claudePublicReviewArgs` drops only the flags that REQUIRE `--print` // (the headless output set plus CLAUDE_PRINT_ONLY_ARGS) for // `tui: true`; every enforcement flag above (`--permission-mode @@ -849,9 +909,15 @@ export function enforcedPublicReviewPosturesForProvider(provider) { return PUBLIC_REVIEW_POSTURES.filter((posture) => enforcesPublicReviewPosture(provider, posture)); } -const enforcesPublicReviewPosture = (provider, posture) => ( - isDirectBinaryProvider(provider) && Boolean(publicReviewRecipe(provider, posture)) -); +// A recipe may declare `enforces: false` when it exists only to name an +// attachable invocation and adds no sandbox of its own (OpenCode's actions +// row): it is still the argv the stage runs, but the schedule UI must keep +// reporting that choice as worktree-only rather than OS-sandboxed. +const enforcesPublicReviewPosture = (provider, posture) => { + if (!isDirectBinaryProvider(provider)) return false; + const recipe = publicReviewRecipe(provider, posture); + return Boolean(recipe) && recipe.enforces !== false; +}; /** * Vendor ids that declare a maintained recipe for `posture`, for naming what a @@ -868,7 +934,8 @@ export function publicReviewCapableVendorIds(posture) { * The no-tool gate requires a maintained recipe: only an enforced argv can * hold a model tool-free. The sandboxed-actions stage is open to EVERY enabled * binary (CLI/TUI) provider — a vendor recipe (Codex, Antigravity, Grok, - * Claude) adds an OS-level sandbox on top, but the stage's baseline isolation + * Claude) adds an OS-level sandbox on top (OpenCode's adds none — its row + * exists only to name the attachable invocation), but the stage's baseline isolation * is the disposable worktree, the stripped child environment, and the * deterministic coordinator owning all forge mutations. API providers have no * binary to spawn and fail closed for both. diff --git a/server/lib/providerVendors.publicReview.test.js b/server/lib/providerVendors.publicReview.test.js index 5e95eaa4ba..a2140ee79d 100644 --- a/server/lib/providerVendors.publicReview.test.js +++ b/server/lib/providerVendors.publicReview.test.js @@ -366,9 +366,62 @@ describe('public-review provider postures', () => { expect(supportsTuiPublicReviewActionsProvider({ id: 'codex-tui', type: 'tui', command: 'codex' })).toBe(false); expect(supportsTuiPublicReviewActionsProvider({ id: 'grok-tui', type: 'tui', command: 'grok' })).toBe(false); expect(supportsTuiPublicReviewActionsProvider(antigravity)).toBe(false); - // …as do vendors with no actions recipe at all, and non-binary records. - expect(supportsTuiPublicReviewActionsProvider({ id: 'opencode-tui', type: 'tui', command: 'opencode' })).toBe(false); + // …as do non-binary records, whatever their vendor. expect(supportsTuiPublicReviewActionsProvider({ id: 'claude-api', type: 'api', command: 'claude' })).toBe(false); + expect(supportsTuiPublicReviewActionsProvider({ id: 'opencode-api', type: 'api', command: 'opencode' })).toBe(false); + }); + + // #6238 — OpenCode's headless actions run is a one-shot `opencode run …`, + // which cannot become an interactive session by dropping flags; the + // attachable recipe is the BARE binary (OpenCode's TUI entry point) with the + // same agent/model flags, and the spawner pastes the prompt as for any TUI. + it('declares OpenCode attachable for the actions stage on every backend, with a bare-binary argv', () => { + const opencodeTui = { id: 'opencode-tui', type: 'tui', command: 'opencode', args: ['--agent', 'plan', '--auto'], ollamaBacked: true }; + expect(supportsTuiPublicReviewActionsProvider(opencodeTui)).toBe(true); + // Unlike the no-tool posture (Ollama-only — see the `mtplxBacked` cases + // above), attachability involves no model-capability probe, so an MTPLX or + // gateway-fronting wrapper is just as attachable. + expect(supportsTuiPublicReviewActionsProvider({ ...opencodeTui, ollamaBacked: false, mtplxBacked: true })).toBe(true); + expect(supportsTuiPublicReviewActionsProvider({ id: 'opencode-cli', type: 'cli', command: 'opencode' })).toBe(true); + expect(supportsTuiPublicReviewPosture(opencodeTui, PUBLIC_REVIEW_NO_TOOL_POSTURE)).toBe(false); + + const headless = buildVendorSpawnConfig(opencodeTui, { + effectiveModel: 'qwen3-coder:30b', + safetyProfile: PUBLIC_REVIEW_ACTIONS_EXECUTION_PROFILE, + }); + // The headless shape is UNCHANGED by the row: still the ordinary + // `run`-prefixed argv with the provider's own args forwarded. + expect(headless).toEqual({ + command: 'opencode', + args: ['run', '--agent', 'plan', '--auto', '-m', 'ollama/qwen3-coder:30b'], + stdinMode: 'prompt', + }); + + const tui = buildVendorSpawnConfig(opencodeTui, { + effectiveModel: 'qwen3-coder:30b', + safetyProfile: PUBLIC_REVIEW_ACTIONS_EXECUTION_PROFILE, + tui: true, + }); + expect(tui).toEqual({ + command: 'opencode', + // No `run` subcommand: that is print mode and never renders in a PTY. The + // tool-enabled agent is pinned on the argv, and the provider's saved args + // (`--agent plan`, `--auto`) are NOT forwarded on the attachable path. + args: ['--agent', 'build', '-m', 'ollama/qwen3-coder:30b'], + stdinMode: 'prompt', + }); + expect(tui.args).not.toContain('run'); + // The model is namespaced the same way on both shapes, so they can't drift. + expect(tui.args.at(-1)).toBe(headless.args.at(-1)); + expect(buildVendorSpawnConfig({ ...opencodeTui, ollamaBacked: false, mtplxBacked: true }, { + effectiveModel: 'x', + safetyProfile: PUBLIC_REVIEW_ACTIONS_EXECUTION_PROFILE, + tui: true, + }).args).toEqual(['--agent', 'build', '-m', 'mtplx/x']); + expect(buildVendorSpawnConfig(opencodeTui, { + safetyProfile: PUBLIC_REVIEW_ACTIONS_EXECUTION_PROFILE, + tui: true, + }).args).toEqual(['--agent', 'build']); }); it('refuses to build an attachable argv for a vendor with no attachable recipe', () => { @@ -378,7 +431,6 @@ describe('public-review provider postures', () => { for (const provider of [ { id: 'codex-tui', type: 'tui', command: 'codex' }, { id: 'grok-tui', type: 'tui', command: 'grok' }, - { id: 'opencode-tui', type: 'tui', command: 'opencode' }, { id: 'antigravity-tui', type: 'tui', command: 'agy' }, ]) { expect(() => buildVendorSpawnConfig(provider, { diff --git a/server/services/agentLifecycle.postureGate.test.js b/server/services/agentLifecycle.postureGate.test.js index 136e7b4b7a..93da61c806 100644 --- a/server/services/agentLifecycle.postureGate.test.js +++ b/server/services/agentLifecycle.postureGate.test.js @@ -345,6 +345,39 @@ describe('public-review posture gate — spawn behavior (#5866)', () => { ); }); + // #6238 — OpenCode's actions row exists for exactly this: a Stage 3 run on an + // OpenCode-backed TUI record gets a PTY (and so an attachable Shell session) + // instead of being forced headless. Its no-tool gate stays blocked above. + it('spawns the sandboxed-actions stage as a PTY on an OpenCode TUI provider', async () => { + const { buildTuiSpawnConfig, spawnTuiAgent } = await import('./agentTuiSpawning.js'); + vi.mocked(resolveAgentProviderAndModel).mockResolvedValue({ + ok: true, provider: { ...OPENCODE_TUI, mtplxBacked: true }, selectedModel: 'example-model', modelSelection: {}, + }); + vi.mocked(buildTuiSpawnConfig).mockReturnValue({ command: 'opencode', args: [], commandLine: 'opencode' }); + reachDispatch(); + + await spawnAgentForTask({ + id: 'task-public-review-actions-opencode', + metadata: { + executionProfile: 'public-review-actions', + issueWatcher: { pullRequests: [{ number: 42 }] }, + pipeline: { + securityScan: { completed: true, status: 'passed', safePrCount: 1 }, + eligibility: { complete: true, eligibleNumbers: [42] }, + }, + }, + }); + + expect(postureBlockWrites()).toEqual([]); + expect(spawnDirectly).not.toHaveBeenCalled(); + expect(spawnTuiAgent).toHaveBeenCalledTimes(1); + expect(buildTuiSpawnConfig).toHaveBeenCalledWith( + expect.objectContaining({ id: 'opencode-tui' }), + 'example-model', + expect.objectContaining({ safetyProfile: 'public-review-actions' }), + ); + }); + // The narrow half of the same rule. A vendor whose actions recipe has not been // reviewed for a PTY emits headless argv (`exec`, `--print`, `run`) that a PTY // can neither prompt nor enforce, so it stays headless rather than opening a From 128a17e503294a976b672c5c41f37cf475d168d5 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Fri, 4 Sep 2026 17:02:14 +0000 Subject: [PATCH 2/3] refactor: derive public-review attachability and enforcement from which argv builders a recipe supplies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recipe now carries `spawnArgs` (headless) and/or `tuiSpawnArgs` (attachable) instead of `tui: true` / `enforces: false` flags: attachable means a row supplies `tuiSpawnArgs`, enforced means it supplies `spawnArgs`. OpenCode's actions row supplies only the attachable builder, so it falls through to the ordinary headless argv, stays worktree-only in the schedule UI, and is left out of the 'install one of these' suggestion — all structurally, with no per-row opt-outs. Also homes OPENCODE_BUILD_AGENT beside OPENCODE_PUBLIC_REVIEW_AGENT, composes isLocalOpencodeProvider from the shared binary matcher, and factors the OpenCode model-append/envelope helpers so the two argv shapes share them. --- server/lib/opencodeConfig.js | 5 +- server/lib/providerModels.js | 7 + server/lib/providerVendors.js | 145 +++++++----------- .../lib/providerVendors.publicReview.test.js | 24 +-- server/services/agentErrorAnalysis.js | 2 +- server/services/agentLifecycle.js | 2 +- 6 files changed, 74 insertions(+), 111 deletions(-) diff --git a/server/lib/opencodeConfig.js b/server/lib/opencodeConfig.js index 2c068c9333..b5be495257 100644 --- a/server/lib/opencodeConfig.js +++ b/server/lib/opencodeConfig.js @@ -38,6 +38,7 @@ import { isOpencodeCommand, prefixOpencodeModel, parseOpencodeConfigContent, + OPENCODE_BUILD_AGENT, OPENCODE_PUBLIC_REVIEW_AGENT, } from './providerModels.js'; import { PROVIDER_GATEWAYS, PROVIDER_GATEWAY_IDS, gatewayById, isGatewayNamespace } from './providerGateways.js'; @@ -408,14 +409,14 @@ function hardenOpencodeConfigForNoTool(config) { config.permission = DENY_ALL_PERMISSIONS; config.tools = { ...DENY_ALL_TOOLS }; const agents = asObject(config.agent); - const agentNames = new Set([...Object.keys(agents), 'build', OPENCODE_PUBLIC_REVIEW_AGENT]); + const agentNames = new Set([...Object.keys(agents), OPENCODE_BUILD_AGENT, OPENCODE_PUBLIC_REVIEW_AGENT]); // `buildAgentGeneration` writes the stage's temperature / topP / thinking / // reasoningEffort onto `agent.build` — OpenCode's default agent — but this // profile runs `--agent plan`. Seed the review agent from `build` so the // stage's configured effort actually reaches the model that runs, instead of // silently falling back to the backend default. An explicit `agent.plan` in // the user's own config still wins (it is spread after). - const generationSource = asObject(agents.build); + const generationSource = asObject(agents[OPENCODE_BUILD_AGENT]); config.agent = Object.fromEntries([...agentNames].map((name) => [name, { ...(name === OPENCODE_PUBLIC_REVIEW_AGENT ? generationSource : {}), ...asObject(agents[name]), diff --git a/server/lib/providerModels.js b/server/lib/providerModels.js index 59ef839a01..eea099dc27 100644 --- a/server/lib/providerModels.js +++ b/server/lib/providerModels.js @@ -618,6 +618,13 @@ export function isOpencodeCommand(command) { */ export const OPENCODE_PUBLIC_REVIEW_AGENT = 'plan'; +/** + * OpenCode's built-in tool-enabled agent — the one an attachable Stage 3 + * session runs as (`providerVendors.js`) and the one `hardenOpencodeConfigForNoTool` + * must still empty. Homed here for the same import-graph reason as above. + */ +export const OPENCODE_BUILD_AGENT = 'build'; + /** * OpenCode addresses models as `provider/model` (e.g. `ollama/qwen2.5:7b`). The * OpenCode Ollama provider declares its local daemon under the config-provider diff --git a/server/lib/providerVendors.js b/server/lib/providerVendors.js index 5ae932540b..450d6c069a 100644 --- a/server/lib/providerVendors.js +++ b/server/lib/providerVendors.js @@ -74,6 +74,7 @@ import { localRuntimeNamespace, opencodeProviderIsLocalOnly, OPENCODE_PUBLIC_REVIEW_AGENT, + OPENCODE_BUILD_AGENT, applyLeanClaudeArgs, } from './providerModels.js'; import { @@ -147,7 +148,7 @@ function defaultSpawnArgs(cliArgsFn, fallbackCommand) { * a vendor (`codex-tui`, `grok-tui`, …) is spawned through that vendor's * headless public-review recipe exactly like its CLI sibling, so the user's * enabled TUI providers are legal stage choices. The one exception is a recipe - * marked `tui: true` (see `supportsTuiPublicReviewPosture`), which the + * supplying `tuiSpawnArgs` (see `supportsTuiPublicReviewPosture`), which the * sandboxed-actions stage may run as an attachable session so an operator can * watch and steer it. API/custom providers have no binary and no recipe. */ @@ -332,15 +333,20 @@ const ANTIGRAVITY = { // ─── opencode ─────────────────────────────────────────────────────────────── -function opencodeCliArgs(baseArgs, { model, provider }) { - const args = baseArgs.includes('run') ? [...baseArgs] : ['run', ...baseArgs]; +/** Append the namespaced `-m` unless the argv already pins a model. */ +function appendOpencodeModel(args, provider, model) { const resolvedModel = prefixOpencodeModel(provider, model); - if (resolvedModel && !hasModelFlag(baseArgs)) { - args.push('-m', resolvedModel); - } + if (resolvedModel && !hasModelFlag(args)) args.push('-m', resolvedModel); return args; } +const opencodeSpawnConfig = (provider, args) => ({ command: provider?.command || 'opencode', args, stdinMode: 'prompt' }); + +function opencodeCliArgs(baseArgs, { model, provider }) { + const args = baseArgs.includes('run') ? [...baseArgs] : ['run', ...baseArgs]; + return appendOpencodeModel(args, provider, model); +} + /** * An OpenCode wrapper this install can actually run the tool-free gate on. * Three conditions, each closing a different way the stage would otherwise be @@ -363,8 +369,9 @@ function opencodeCliArgs(baseArgs, { model, provider }) { * unrepresentable. * - **a spawnable binary**, as for every other vendor. */ -const isLocalOpencodeProvider = (provider) => isDirectBinaryProvider(provider) - && isOpencodeCommand(provider?.command) +const matchOpencodeBinary = (provider) => isDirectBinaryProvider(provider) && isOpencodeCommand(provider?.command); + +const isLocalOpencodeProvider = (provider) => matchOpencodeBinary(provider) && localRuntimeNamespace(provider) === 'ollama' && opencodeProviderIsLocalOnly(provider); @@ -385,64 +392,30 @@ const isLocalOpencodeProvider = (provider) => isDirectBinaryProvider(provider) * onto this agent (see `hardenOpencodeConfigForNoTool`). */ function opencodePublicReviewSpawnArgs(provider, { effectiveModel } = {}) { - return { - command: provider?.command || 'opencode', - args: opencodeCliArgs(['--agent', OPENCODE_PUBLIC_REVIEW_AGENT], { model: effectiveModel, provider }), - stdinMode: 'prompt', - }; + return opencodeSpawnConfig(provider, opencodeCliArgs(['--agent', OPENCODE_PUBLIC_REVIEW_AGENT], { model: effectiveModel, provider })); } -const opencodeHeadlessSpawnArgs = defaultSpawnArgs(opencodeCliArgs, 'opencode'); - -/** - * The tool-enabled agent an attachable Stage 3 session runs as. Named on the - * argv rather than left to OpenCode's default so the session cannot inherit a - * `plan` (read-only, hand-back-to-a-human) default from a user's own config. - */ -const OPENCODE_PUBLIC_REVIEW_ACTIONS_AGENT = 'build'; - /** - * The `sandboxed-actions` recipe. OpenCode ships no OS sandbox of its own, so - * this adds NO enforcement over the open-to-every-binary tier (the disposable - * worktree, the stripped child env, and the deterministic coordinator are the - * isolation — see `supportsPublicReviewPosture`). The row exists for the - * ATTACHABLE case (#6238): a headless run is still today's `opencode run …`, - * argv-for-argv, but that one-shot print-mode invocation cannot become an - * interactive session by dropping flags the way Claude's recipe does — in a - * PTY it neither accepts a pasted prompt nor renders anything. OpenCode's real - * interactive entry point is the BARE binary (`opencode [project]`, its default - * subcommand), which takes the same `--agent`/`-m` flags and reads the prompt - * the spawner already pastes into every other TUI (`agentTuiSpawning.js`). - * Verified under node-pty against a local Ollama backend: the pasted prompt - * renders, Enter submits it, and the build agent answers through its tools. + * The ATTACHABLE `sandboxed-actions` invocation (#6238). OpenCode's headless + * argv is a one-shot `opencode run …`, which cannot become an interactive + * session by dropping flags the way Claude's recipe does — in a PTY it neither + * accepts a pasted prompt nor renders. Its real interactive entry point is the + * BARE binary, which takes the same `--agent`/`-m` flags and reads the prompt + * the spawner pastes into every TUI (`agentTuiSpawning.js`). The tool-enabled + * agent is pinned on the argv and provider args are not forwarded (same rule as + * `buildTuiSpawnConfig`): a saved `--agent plan` would hand the session to a + * human nobody has to be. Permissions are the config's, on both paths — every + * tool allowed, the interactive gates denied (`buildOpencodeEnvVars`). * - * `-m` is namespaced by `prefixOpencodeModel` exactly as the headless argv is, - * so the two shapes cannot drift on the model. Provider args are not forwarded - * on the attachable path (same rule as `buildTuiSpawnConfig`): a saved - * `--agent plan` would swap the tool-enabled agent for one that hands back to a - * human nobody has to be, and `--auto` is redundant with the config's blanket - * allow. The permission surface is the same on both paths: the config - * `buildOpencodeEnvVars` composes allows every tool and denies the three - * interactive gates (`question` / `plan_enter` / `plan_exit`), so an unattended - * session never parks on a selector — while an operator who attaches sees the - * same run the headless child would have executed. + * The row supplies ONLY this builder: a headless actions run keeps falling + * through to the ordinary `run` argv, and with no headless `spawnArgs` the row + * is not an enforcement (OpenCode ships no sandbox; the disposable worktree is + * the isolation), so the schedule UI keeps reporting it as worktree-only. */ -function opencodePublicReviewActionsSpawnArgs(provider, { effectiveModel, effort, tui = false } = {}) { - if (!tui) return opencodeHeadlessSpawnArgs(provider, { effectiveModel, effort }); - const args = ['--agent', OPENCODE_PUBLIC_REVIEW_ACTIONS_AGENT]; - const resolvedModel = prefixOpencodeModel(provider, effectiveModel); - if (resolvedModel) args.push('-m', resolvedModel); - return { command: provider?.command || 'opencode', args, stdinMode: 'prompt' }; +function opencodePublicReviewActionsTuiSpawnArgs(provider, { effectiveModel } = {}) { + return opencodeSpawnConfig(provider, appendOpencodeModel(['--agent', OPENCODE_BUILD_AGENT], provider, effectiveModel)); } -/** - * Any spawnable OpenCode binary, whatever it fronts. Unlike the no-tool - * matcher there is no model-capability probe involved, so an MTPLX / llama.cpp - * / vLLM / gateway backend is as eligible as Ollama — the actions stage is open - * to every binary provider anyway; this row only decides attachability. - */ -const matchOpencodeBinary = (provider) => isDirectBinaryProvider(provider) && isOpencodeCommand(provider?.command); - const OPENCODE = { id: 'opencode', idFragment: 'opencode', @@ -452,22 +425,17 @@ const OPENCODE = { // (buildVendorCliArgs/buildVendorSpawnConfig fall back to matchCommand when // matchCliProvider is absent). cliArgs: opencodeCliArgs, - spawnArgs: opencodeHeadlessSpawnArgs, + spawnArgs: defaultSpawnArgs(opencodeCliArgs, 'opencode'), publicReview: { [PUBLIC_REVIEW_NO_TOOL_POSTURE]: { spawnArgs: opencodePublicReviewSpawnArgs, matchProvider: isLocalOpencodeProvider, }, [PUBLIC_REVIEW_ACTIONS_POSTURE]: { - spawnArgs: opencodePublicReviewActionsSpawnArgs, + // Attachable on every backend (MTPLX and gateways included): unlike the + // no-tool gate there is no model-capability probe involved. + tuiSpawnArgs: opencodePublicReviewActionsTuiSpawnArgs, matchProvider: matchOpencodeBinary, - // Not an enforcement: the stage stays worktree-only in the schedule UI. - enforces: false, - // Attachable: the bare-binary shape above is what the PTY runs. There is - // no argv-level permission boundary to preserve here — the headless run - // already executes with the same full tool access — so attaching widens - // nothing; the recipe only supplies the invocation a PTY can drive. - tui: true, }, }, }; @@ -651,7 +619,7 @@ const CLAUDE_PUBLIC_REVIEW_ACTIONS_ARGS = [ // Flags Claude Code accepts ONLY alongside `--print`, mapped to whether they // consume the following argv entry as their value. The posture arrays above are -// written for the headless launch, so an attachable (`tui: true`) recipe has to +// written for the headless launch, so the attachable `tuiSpawnArgs` recipe has to // drop them — the CLI refuses to start at all otherwise: // // Error: --no-session-persistence can only be used with --print mode. @@ -747,7 +715,7 @@ const CLAUDE = { // lever that lifts Claude Code's filesystem protection is // `sandbox.filesystem.disabled`, which this recipe never emits, and // `--disable-slash-commands` removes the in-session settings surface. - tui: true, + tuiSpawnArgs: claudePublicReviewSpawnArgsFor(CLAUDE_PUBLIC_REVIEW_ACTIONS_ARGS), }, }, }; @@ -791,7 +759,7 @@ export function publicReviewRecipe(provider, posture) { if (!PUBLIC_REVIEW_POSTURES.includes(posture)) return null; for (const vendor of PROVIDER_VENDORS) { const recipe = vendor.publicReview?.[posture]; - if (recipe?.spawnArgs && recipe.matchProvider(provider)) return recipe; + if ((recipe?.spawnArgs || recipe?.tuiSpawnArgs) && recipe.matchProvider(provider)) return recipe; } return null; } @@ -868,12 +836,12 @@ export function buildVendorSpawnConfig(provider, ctx) { // session whose posture is decorative. Callers decide TUI-vs-headless from // `supportsTuiPublicReviewPosture`, so reaching this is a routing bug. if (ctx?.tui) { - if (!recipe?.tui) { + if (!recipe?.tuiSpawnArgs) { throw new Error(`Provider '${providerLabel(provider)}' has no attachable ${posture} public-review recipe`); } - return recipe.spawnArgs(provider, ctx); + return recipe.tuiSpawnArgs(provider, ctx); } - if (recipe) return recipe.spawnArgs(provider, ctx); + if (recipe?.spawnArgs) return recipe.spawnArgs(provider, ctx); // See supportsPublicReviewPosture for why the actions stage may fall // through to the vendor's ordinary headless recipe and the gate may not. if (!supportsPublicReviewPosture(provider, posture)) { @@ -893,7 +861,7 @@ export function buildVendorSpawnConfig(provider, ctx) { * API/custom providers have no maintained recipe: a generic read-only prompt * is not enforcement, so they fail closed. A TUI record IS eligible — the * stage spawns its binary through the vendor's enforced recipe, headless unless - * that recipe is also marked `tui: true` (see `isDirectBinaryProvider` and + * that recipe also supplies `tuiSpawnArgs` (see `isDirectBinaryProvider` and * `supportsTuiPublicReviewPosture`). */ export function publicReviewPosturesForProvider(provider) { @@ -909,15 +877,13 @@ export function enforcedPublicReviewPosturesForProvider(provider) { return PUBLIC_REVIEW_POSTURES.filter((posture) => enforcesPublicReviewPosture(provider, posture)); } -// A recipe may declare `enforces: false` when it exists only to name an -// attachable invocation and adds no sandbox of its own (OpenCode's actions -// row): it is still the argv the stage runs, but the schedule UI must keep +// A row that supplies the HEADLESS argv is an enforcement; one that supplies +// only an attachable invocation (OpenCode's actions row) is not — the stage +// still falls through to the vendor's ordinary argv and the schedule UI keeps // reporting that choice as worktree-only rather than OS-sandboxed. -const enforcesPublicReviewPosture = (provider, posture) => { - if (!isDirectBinaryProvider(provider)) return false; - const recipe = publicReviewRecipe(provider, posture); - return Boolean(recipe) && recipe.enforces !== false; -}; +const enforcesPublicReviewPosture = (provider, posture) => ( + isDirectBinaryProvider(provider) && Boolean(publicReviewRecipe(provider, posture)?.spawnArgs) +); /** * Vendor ids that declare a maintained recipe for `posture`, for naming what a @@ -934,8 +900,7 @@ export function publicReviewCapableVendorIds(posture) { * The no-tool gate requires a maintained recipe: only an enforced argv can * hold a model tool-free. The sandboxed-actions stage is open to EVERY enabled * binary (CLI/TUI) provider — a vendor recipe (Codex, Antigravity, Grok, - * Claude) adds an OS-level sandbox on top (OpenCode's adds none — its row - * exists only to name the attachable invocation), but the stage's baseline isolation + * Claude) adds an OS-level sandbox on top, but the stage's baseline isolation * is the disposable worktree, the stripped child environment, and the * deterministic coordinator owning all forge mutations. API providers have no * binary to spawn and fail closed for both. @@ -986,17 +951,17 @@ export function supportsPublicReviewActionsProvider(provider) { * Deliberately much narrower than `supportsPublicReviewPosture`: that one lets * the actions stage fall through to a vendor's ordinary headless recipe when it * declares none, which is fine for a `--print` child and useless in a PTY. An - * interactive session requires a recipe that has been reviewed for it and says - * so with `tui: true` — the recipe still owns the argv (`spawnArgs(provider, - * { ...ctx, tui: true })`), it just drops the flags that only work under - * `--print`. + * interactive session requires a recipe that has been reviewed for it and + * supplies `tuiSpawnArgs` — the argv a PTY can drive (for Claude the headless + * argv minus the flags that only work under `--print`; for OpenCode a + * different entry point entirely). * * `no-tool` is structurally excluded: an interactive session for a reasoner * with no tools buys nothing and widens the boundary for free, so no row * declares it and this returns false for that posture by construction. */ export function supportsTuiPublicReviewPosture(provider, posture) { - return isDirectBinaryProvider(provider) && Boolean(publicReviewRecipe(provider, posture)?.tui); + return isDirectBinaryProvider(provider) && Boolean(publicReviewRecipe(provider, posture)?.tuiSpawnArgs); } /** Whether the sandboxed final public-review stage can attach a PTY here. */ diff --git a/server/lib/providerVendors.publicReview.test.js b/server/lib/providerVendors.publicReview.test.js index a2140ee79d..94c7389b3b 100644 --- a/server/lib/providerVendors.publicReview.test.js +++ b/server/lib/providerVendors.publicReview.test.js @@ -369,22 +369,20 @@ describe('public-review provider postures', () => { // …as do non-binary records, whatever their vendor. expect(supportsTuiPublicReviewActionsProvider({ id: 'claude-api', type: 'api', command: 'claude' })).toBe(false); expect(supportsTuiPublicReviewActionsProvider({ id: 'opencode-api', type: 'api', command: 'opencode' })).toBe(false); + // #6238 — OpenCode is attachable on EVERY backend: unlike the no-tool gate + // (Ollama-only — see the `mtplxBacked` cases above) there is no + // model-capability probe involved, so an MTPLX or gateway wrapper qualifies. + expect(supportsTuiPublicReviewActionsProvider({ id: 'opencode-tui', type: 'tui', command: 'opencode', mtplxBacked: true })).toBe(true); + expect(supportsTuiPublicReviewActionsProvider({ id: 'opencode-cli', type: 'cli', command: 'opencode' })).toBe(true); + expect(supportsTuiPublicReviewPosture({ id: 'opencode-tui', type: 'tui', command: 'opencode', ollamaBacked: true }, PUBLIC_REVIEW_NO_TOOL_POSTURE)).toBe(false); }); // #6238 — OpenCode's headless actions run is a one-shot `opencode run …`, // which cannot become an interactive session by dropping flags; the // attachable recipe is the BARE binary (OpenCode's TUI entry point) with the // same agent/model flags, and the spawner pastes the prompt as for any TUI. - it('declares OpenCode attachable for the actions stage on every backend, with a bare-binary argv', () => { + it('builds the attachable OpenCode actions argv as the bare binary while the headless argv is unchanged', () => { const opencodeTui = { id: 'opencode-tui', type: 'tui', command: 'opencode', args: ['--agent', 'plan', '--auto'], ollamaBacked: true }; - expect(supportsTuiPublicReviewActionsProvider(opencodeTui)).toBe(true); - // Unlike the no-tool posture (Ollama-only — see the `mtplxBacked` cases - // above), attachability involves no model-capability probe, so an MTPLX or - // gateway-fronting wrapper is just as attachable. - expect(supportsTuiPublicReviewActionsProvider({ ...opencodeTui, ollamaBacked: false, mtplxBacked: true })).toBe(true); - expect(supportsTuiPublicReviewActionsProvider({ id: 'opencode-cli', type: 'cli', command: 'opencode' })).toBe(true); - expect(supportsTuiPublicReviewPosture(opencodeTui, PUBLIC_REVIEW_NO_TOOL_POSTURE)).toBe(false); - const headless = buildVendorSpawnConfig(opencodeTui, { effectiveModel: 'qwen3-coder:30b', safetyProfile: PUBLIC_REVIEW_ACTIONS_EXECUTION_PROFILE, @@ -410,14 +408,6 @@ describe('public-review provider postures', () => { args: ['--agent', 'build', '-m', 'ollama/qwen3-coder:30b'], stdinMode: 'prompt', }); - expect(tui.args).not.toContain('run'); - // The model is namespaced the same way on both shapes, so they can't drift. - expect(tui.args.at(-1)).toBe(headless.args.at(-1)); - expect(buildVendorSpawnConfig({ ...opencodeTui, ollamaBacked: false, mtplxBacked: true }, { - effectiveModel: 'x', - safetyProfile: PUBLIC_REVIEW_ACTIONS_EXECUTION_PROFILE, - tui: true, - }).args).toEqual(['--agent', 'build', '-m', 'mtplx/x']); expect(buildVendorSpawnConfig(opencodeTui, { safetyProfile: PUBLIC_REVIEW_ACTIONS_EXECUTION_PROFILE, tui: true, diff --git a/server/services/agentErrorAnalysis.js b/server/services/agentErrorAnalysis.js index a8954bb9c8..dc1159a464 100644 --- a/server/services/agentErrorAnalysis.js +++ b/server/services/agentErrorAnalysis.js @@ -221,7 +221,7 @@ export const ERROR_PATTERNS = [ const requires = redactFailureSnippet(match[2] || '').replace(/[.,;]+$/, '').slice(0, CONFIG_EXPECTED_MAX_CHARS); return { message: `Provider CLI rejected the flag ${flag}`, - suggestedFix: `The CLI exited while parsing its arguments, before the prompt was delivered, so every retry fails identically.${requires ? ` It reports that \`${flag}\` only works with ${requires}.` : ''} PortOS builds this argv itself — fix the posture/vendor recipe in server/lib/providerVendors.js (an attachable \`tui: true\` recipe must drop every flag that requires \`--print\`), not the provider record.`, + suggestedFix: `The CLI exited while parsing its arguments, before the prompt was delivered, so every retry fails identically.${requires ? ` It reports that \`${flag}\` only works with ${requires}.` : ''} PortOS builds this argv itself — fix the posture/vendor recipe in server/lib/providerVendors.js (an attachable \`tuiSpawnArgs\` recipe must drop every flag that requires \`--print\`), not the provider record.`, rejectedCliFlag: flag }; } diff --git a/server/services/agentLifecycle.js b/server/services/agentLifecycle.js index 789415fd61..097947bb12 100644 --- a/server/services/agentLifecycle.js +++ b/server/services/agentLifecycle.js @@ -444,7 +444,7 @@ async function runAgentSpawn(task) { // patch and runs the repo's tests — so it is the one an operator actually // wants to attach to and steer. It may run as an interactive session when // its configured provider is a TUI record AND that vendor declares an - // attachable recipe (`tui: true`), which keeps every enforcement flag and + // attachable recipe (`tuiSpawnArgs`), which keeps every enforcement flag and // drops only the headless output flags. // // Everything else stays headless. The `no-tool` postures (Stage 1's From d3f9c5a8044749279b5a6df97fb5daf7eaf279cc Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Fri, 4 Sep 2026 17:06:31 +0000 Subject: [PATCH 3/3] docs: say when the OpenCode actions recipe's config posture actually applies (local-only endpoints) --- server/lib/providerVendors.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/lib/providerVendors.js b/server/lib/providerVendors.js index 450d6c069a..f614064eea 100644 --- a/server/lib/providerVendors.js +++ b/server/lib/providerVendors.js @@ -405,7 +405,11 @@ function opencodePublicReviewSpawnArgs(provider, { effectiveModel } = {}) { * agent is pinned on the argv and provider args are not forwarded (same rule as * `buildTuiSpawnConfig`): a saved `--agent plan` would hand the session to a * human nobody has to be. Permissions are the config's, on both paths — every - * tool allowed, the interactive gates denied (`buildOpencodeEnvVars`). + * tool allowed, the interactive gates denied (`buildOpencodeEnvVars`) — for as + * long as `OPENCODE_CONFIG_CONTENT` survives the actions env allowlist, i.e. a + * local-only endpoint (`cliChildEnv.js`); a gateway-backed wrapper runs on the + * operator's own `~/.config/opencode` instead, exactly as its headless run + * already did. * * The row supplies ONLY this builder: a headless actions run keeps falling * through to the ordinary `run` argv, and with no headless `spawnArgs` the row