Repository navigation
feat(server): OpenCode 2 approvals, questions and every runtime mode - #14407
Conversation
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR adds substantial OpenCode 2 production behavior: runtime permission enforcement, mode switching, approval flows, structured questions, and request lifecycle handling. Because these changes alter execution and permission boundaries and introduce a new user-facing workflow, human review is warranted. No code changes detected at You can add or adjust custom eligibility rules. Learn more. |
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: unavailable · PR result: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
c2515cf to
608cbda
Compare
a6dcb34 to
3ce44b5
Compare
608cbda to
dfef90f
Compare
e664e24 to
9aa4df1
Compare
dfef90f to
6fa4c03
Compare
9aa4df1 to
5a5fc1c
Compare
5a5fc1c to
0279287
Compare
Each runtime mode is now a set of OpenCode 2 session permission rules (v2 action names), written at session.create and rewritten by session.update on resume and before each turn when the mode or plan mode changed. Permission asks become approval requests and question-tool forms become user input requests on the asking session's thread, including a subagent's. Answers go to permission.reply and session.form.reply/cancel. "Allow this session" is a rule on the session, not OpenCode's `always`, which saves a grant for the whole project. A decline steers a note into the session first, so the model stops retrying the declined call. Requests a restarted T3 no longer shows are stopped when it loads the session again. Forms with fields T3 cannot ask are declined with the reason. The Full-access-only refusal and the auto-answers are gone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The failure can carry the server's URL or response text, so it stays in the log's cause and only its tag is annotated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A reply or cancel for a request OpenCode already dropped counts as delivered. Each call site now catches exactly the not-found tags its client call can fail with, instead of a tag set checked at runtime. A test pins that such an answer does not fail the turn. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
0279287 to
5e6e896
Compare
Layer 4 left OpenCode 2 in Full access only: other modes were refused, permission asks were auto-rejected, and question forms were auto-answered. This layer maps every runtime mode onto OpenCode 2 session rules, and shows real approvals and questions.
Runtime modes → session
permissionsRules use the v2 action names and are built by one function,
sessionRules(policy, paths, grants), so later layers can append their own rules (layer 9:[...modeRules, ...mcpRules]).* * allowshell ask,edit ask,external_directory askshell ask,edit allow,external_directory askedit * deny, then the plan agent's ownedit/external_directoryallows for~/.opencode/plan/*subagent * denylast (until layer 6)Permission.evaluateisrules.findLast(match(action) && match(resource)) ?? {effect:"ask"}over[...agentRules, ...sessionRules, ...savedGrants]. Session rules therefore override the agent's own rules. That is why T3's old* * allowlet the plan agent write workspace files.GET /api/agentand appended after the blanket rules, only for the modes that narrow Full access. Without them, reading a truncated shell output would ask (seen live), and plan mode could not write its plan.denyon shell or read. DESIGN risk 2: the free tier refuses such sessions. T3 has no read-only runtime mode, so nothing needs "ask plus automatic reject". Auto has no OpenCode reviewer, so it asks like Supervised, matching 1.x.supportsRuntimeModeSwitchInSessionis now true, so a mode change detaches nothing. The next turn resumes the same session and rewrites its rules viasession.update(diffed, so it's skipped when nothing changed).startTurnalso rewrites them, which covers plan-mode toggles.supportedRuntimeModesis now all four modes; the Full-access-only refusal is gone.Approvals
permission.askedbecomes an approval request on the asking session's thread and running turn. The kind comes from the tool (command,file-changeorfile-read, via the 1.x helper), and the prompt shows the resources.session.created{parentID}; verified live, see below. Layer 6 builds subagents on this.once.rejectwith a message, which fails only that tool call.rejectwithout a message, which OpenCode treats as "stop" and ends the whole execution. That matched Cancel, so it was kept.alwaysis not session-scoped.Permission.replysaves it withPermissionSaved.add({projectID, …}).alwaysonecho *in one session, a brand-new session in the same directory ranecho …without asking.GET /api/permission/saved?location[directory]=…then listed the grant.always. "Allow<save pattern>this session" (for example "Allow echo * this session") adds{action, resource: save, effect: allow}to this session's own rules and repliesonce.savepatterns.messagenever reaches the model: a declined shell call reads only as "Unable to execute command", so the model retried (the spike saw this 3×).POST /session/{id}/synthetic,delivery: "steer",resume: false), then rejects.permission.replied,form.repliedandform.cancelledfrom other clients settle T3's copy.pendingRequestsSurviveRestart: false, so the orchestrator expires them). When a runtime first loads a session, it listsGET session/{id}/permissionandGET session/{id}/form. It stops anything still open, because T3 no longer shows it. It then skips that stopped run's end, so the next turn isn't ended by it.Questions
form.created(from thequestiontool) becomes a structured user-input request:value;custommaps toallowCustomAnswer;multiselectmaps tomultiSelect, and its answer goes back asstring[].session.form.reply; Cancel or Decline goes tosession.form.cancel.questiontool call itself is not shown as an item; the form is the item, as in 1.x.when,hidden,externaland number/boolean fields aren't supported. V2's model has no conditional, hidden, link or typed fields. Such a form is cancelled at once, and the turn fails with the reason (for example "OpenCode asked for a link to open, which T3 Code can't show"). It never hangs. Thequestiontool only produces string and multiselect fields.Verification
OrchestratorReplayFixtures.integration.test.ts: all 103 pass. The two new ones, converted from the spike recordings:opencode2_permission(permission.ndjson, Supervised): approve once, then decline the command and the model's two retries. It asserts four resolved requests, one run and three failed shell items, and the session-scoped option.opencode2_question(question.ndjson): the form becomes a question with options and a custom answer, the answer goes back, and the reply uses it.OpenCode2OrchestratorV2.integration.test.ts: 7 of 7 pass. New tests:session.updateeach time;OpenCode2AdapterV2.test.ts: 33 of 33 pass. New direct tests cover failures a live server can't produce on demand:externalform declined with its reason;OpenCodeProvider.test.tsandOrchestratorReplayFixtures.contract.test.tspass.tscexits 0.vp linton the touched files shows only an existingshared.tswarning.vp run knip:checkexits 0.ns-audit.pyflags no lines I changed.OpenCode2OrchestratorV2.live.test.ts, isolated HOME/XDG), in a Supervised thread:touch approved.txtasked, was approved, and ran;touch declined.txtasked, was declined, and did not run (the steered note is in the session history);~/.opencode/plan/probe-plan.mdsucceeded, and the write toplan_write_probe.txtin the workspace was blocked.opencode/big-pickle, and again onopenrouter/deepseek/deepseek-v4-flash(key passed only through the test's environment). The layer 4 live test also passes.alwayspersists project-wide;session.updateapplies to later asks;sessionIDaftersession.created{parentID};Left out
planagent (layer 8). The plan rules already hold with any agent: verified live with both the plan agent and the build agent.sessionRules.Model: Claude Opus 5.5 (Claude Code)
🤖 Generated with Claude Code