Repository navigation
Conversation
… support
OpenChamber injects its `openchamber` control tool into every managed OpenCode
process. Injection happens at process start, before any model is known, so the
tool is declared even when the selected model cannot call tools. Providers that
reject function calling outright then fail every send — Vertex Gemini image
models ("Nano Banana") return:
Unable to submit request because the model does not support function calling
The model's own `tool_call: false` does not help: OpenCode assigns
`capabilities.toolcall` but never reads it to gate a request. Tools are filtered
only by permission name, and no built-in agent denies `openchamber`, so the
injected tool survives every stock agent. Today the only workaround is a
hand-written agent with a wildcard `permission: {"*": deny}`, which no ordinary
user discovers.
Shared UI now sends OpenCode's per-send `tools` map, derived from the selected
model's `capabilities.toolcall`.
Two constraints shape the contract, both verified against opencode 1.18.15:
- `tools` does not filter one request. OpenCode converts it into the session's
permission ruleset and persists it, replacing whatever was there. A one-shot
deny would strand the tool off for the rest of the session, so the complete
desired map is sent on every send and model switches self-correct.
- When `agentControlToolEnabled` is false the tool was never injected, so no
`tools` field is sent and session permissions are left untouched.
Unknown capability is treated as tool-capable: model metadata may not have
loaded yet, and defaulting to disabled would silently remove the tool from
models that support it.
The tool ID is now exported as `OPENCHAMBER_AGENT_TOOL_NAME` and interpolated
into the generated plugin, so the name the client gates and the name the plugin
registers cannot drift.
Verified end to end against a recording proxy with the real generated plugin
loaded: with the gate, `openchamber` is absent from the Vertex request's
`functionDeclarations`; sending the map with the tool enabled restores it in the
same session.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Code Review SummaryThis PR fixes a real bug: OpenChamber injects its I verified the core contract against the actual pinned stack ( Verdict: PASS Reviewed HEAD:
|
| Source | Why applicable | Rules/invariants evaluated |
|---|---|---|
AGENTS.md |
Any OpenChamber source change; the bug is a runtime-contract failure, not a display issue | "Prefer authoritative state over heuristics" (gate uses capabilities.toolcall, not model-name matching); "Enforce security and correctness in core/runtime logic" (gate lives in the shared send path); "Runtime-specific differences must be intentional and visible" (VS Code/external OpenCode not injected); "Never let fetch failure masquerade as empty success" (not implicated) |
CONTRIBUTING.md + .github/PULL_REQUEST_TEMPLATE.md |
Contribution-contract and evidence requirements | All template sections completed with concrete content; validation states exact commands, results, and what was not verified; visual-evidence section gives a concrete reason why no screenshot is feasible (request-level change, needs live Vertex credentials); risks/rollback section covers the persisted-ruleset contract |
.agents/skills/openchamber-change-discipline/SKILL.md |
Source, export, persisted-contract, and documentation change | Smallest-complete-change; persisted/external-contract analysis (session ruleset is OpenCode-owned persisted data); new files/exports require bun run dead-code (claimed run, no findings); owning DOCUMENTATION.md updated |
.agents/skills/ui-api-decoupling/SKILL.md (+ references/runtime-parity.md, references/implementation-map.md) |
Shared-UI SDK call change (tools on promptAsync) |
Official OpenCode API goes through opencodeClient/SDK verbatim (no hand-built request); runtime state resolved at call time inside send; parity for web/desktop/VS Code/mobile considered and documented; VS Code route decisions consulted |
packages/web/server/lib/agent-tool/DOCUMENTATION.md |
Owning module doc for the injected tool | Updated with "Per-send model gating" section; runtime-parity section (VS Code/external not injected) read for the affected-surfaces claims |
packages/ui/src/sync/DOCUMENTATION.md |
Send-path ownership (session-ui-store.ts is the SDK-facing send entrypoint) |
No sync/reducer state added by the gate; send path ownership respected |
External verification: @opencode-ai/[email protected] type definitions and OpenCode v1.18.15 server source (session/prompt.ts, session/tools.ts, permission/index.ts, session/server handlers, SDK types.gen.ts) |
The PR's central claim — that per-send tools maps to a persisted, replacing session permission ruleset — is a claim about OpenCode behavior |
Confirmed: tools → ruleset replacement + setPermission persistence; prompt_async honors tools; deny rules filter tools from registry.tools. Also surfaced two facts the PR does not mention: tools is marked @deprecated in 1.18.15, and the supported replacement (session.update with permission) merges instead of replacing |
Findings
-
non-blocker: the fix is built on an OpenCode field that 1.18.15 already marks deprecated
File:packages/ui/src/sync/agent-tool-gate.ts(and module docpackages/web/server/lib/agent-tool/DOCUMENTATION.md)
Problem: OpenCode 1.18.15 annotatesPromptInput.toolswith@deprecated tools and permissions have been merged, you can set permissions on the session itself now, and the supported path (session.updatewith apermissionpayload) merges rather than replaces. The PR's compatibility section only addresses older OpenCode versions, not the deprecation trajectory. If a future OpenCode/SDK bump removes or alters the field, the gate silently stops working and image-model sends regress with no error signal. Works today against the pinned 1.18.15, so this does not block, but the module doc should state the deprecation and the migration path.
Suggested fix: document the deprecation and thesession.updatepermission alternative in the "Per-send model gating" doc section; track switching tosession.updatewhen the field is removed. -
non-blocker: every gated send replaces the session permission ruleset; the "Unaffected" claims for external OpenCode and VS Code are overbroad at the request level
File:packages/ui/src/sync/session-ui-store.ts:220(gate wiring)
Problem: verified insession/prompt.tsthat a send carryingtoolssetssession.permission = permissions(replace + persist), whereassession.updatepermission merges with the existing ruleset. The PR's mitigation ("complete desired map is sent every time") is only complete for theopenchamberslot — any other rule in the session ruleset is erased on the next normal send. Today the managed-case ruleset is otherwise empty (OpenChamber never writes it), so there is no user-visible regression; but (a) any other session-permission writer (session.updatePATCH,session.createwith permission) is silently clobbered, and (b) both external OpenCode (OPENCODE_HOST) and VS Code sends flow through the same shared store path (verified:packages/vscode/webview/main.tsx→useSessionUIStore.sendMessage→routeMessage), so their requests do rewrite the ruleset to[{openchamber, allow|deny, "*"}]on every send — the rule is inert there (tool not injected), but "Unaffected" in the affected-surfaces table is inaccurate at the request level. Not a security issue: a wipeddenydowngrades toask, which re-prompts rather than silently granting.
Suggested fix: correct the affected-surfaces/risk wording to state that external OpenCode and VS Code sends still carry the map and rewrite the (usually empty) ruleset, and note the replace-vs-merge difference versussession.update. -
non-blocker:
reviewFlowandMultiRunFusionsends bypass the gate
File:packages/ui/src/lib/reviewFlow.ts:390,packages/ui/src/components/multirun/MultiRunFusionDialog.tsx:169
Problem: both callopencodeClient.sendMessagedirectly and never resolveresolveAgentToolGate, so a send targeting a non-tool-calling model from auto-review or multirun fusion still declares the tool and hits the same provider rejection the PR fixes. The PR explicitly documents the slash-command and shell exclusions but not these two normal-prompt paths. Scope decision, not a defect, but it should be stated (or these paths gated, since they are normal prompts).
Suggested fix: either route these through the gate (they haveproviderID/modelIDat hand) or add them to the documented non-goals. -
non-blocker: no test covers the store-level glue
File:packages/ui/src/sync/session-ui-store.ts(gate wiring); existingsession-ui-store.test.js"routeMessage skill invocation" block already capturessendMessageparams
Problem: the pure-function tests (agent-tool-gate.test.ts) and theclient.test.tsforwarding tests are good, but nothing asserts thatrouteMessagepasses the resolvedtoolsmap (fromgetModelMetadata(...).tool_callandagentControlToolEnabled) intosendMessage. A regression in the metadata read, the setting read, or the parameter wiring would pass CI.
Suggested fix: one store-level test insession-ui-store.test.jsassertingtoolsis{ openchamber: true|false }/ absent for the three gate states.
Evidence and Residual Risk
- Review evidence: the diff's tests are relevant and sufficient for the changed contract — 5 pure-function gate tests, 2
sendMessageforwarding tests (verbatimtoolspass-through; field omitted when no gate), and 1 runtime test binding the exported name to the generated plugin's tool key. The PR's stated validation (focused unit runs, type-check/lint, dead-code, end-to-end via a recording proxy showingopenchamberabsent fromfunctionDeclarationsand restored in-session) is proportionate and current for this HEAD. No CI status was used in forming this verdict. I additionally verified the mechanism against the pinned OpenCode 1.18.15 SDK types and server source. - Security/supply-chain: no new dependencies, credentials, tokens, logs, or network surfaces. The gate can only deny OpenChamber's own tool relative to today's behavior; unknown-rule downgrade is deny→ask, never a silent grant. No supply-chain exposure.
- Residual risk: I could not execute the PR branch or run its checks (read-only review). Live Vertex traffic was not exercised (the PR discloses this); the wipe-out finding (2) is real in the code but has no practical impact today because the managed-case session ruleset is otherwise empty — monitor any future session-permission writer, and re-check the
toolsfield on the next OpenCode/SDK bump (finding 1).
Review follow-ups to the per-send tool gate. Behavior fix — the gate no longer consults `agentControlToolEnabled`. That setting only takes effect on the next managed OpenCode restart, so between toggling it off and restarting, the tool is still injected while the gate went silent — reintroducing the provider rejection on exactly the configuration the user believes is disabled, with no restart badge to hint at it. Naming a tool that was never injected is inert (OpenCode filters the tools that exist and matches rule names as literal wildcards), so sending the map unconditionally is safe and keeps the gate honest when intent and process disagree. This also resolves the contradiction with the module's documented runtime parity, where VS Code and external OpenCode never receive the tool. Documentation fix — the previous claim that `tools` "does not filter one request" was wrong. It is stored on the user message and filters that request (`session/llm/request.ts` `resolveTools` checks `user.tools?.[k] !== false`) and it also replaces the persisted session ruleset. The operative guidance is unchanged, but the load-bearing rationale was inaccurate. Also documented, all verified against opencode 1.18.15: - Ruleset replacement is wholesale, so OpenChamber assumes sole ownership of that slot; a rule set by another OpenCode client is discarded by the next send. - Subagent sessions inherit `deny` rules from the parent, so a subagent spawned while an image model was selected starts without the control tool. - Slash-command and shell routes accept no `tools` field at all, and a command's pinned model outranks the client's, so those paths stay ungated pending an upstream API change. - Upstream marks `tools` `@deprecated`; the non-deprecated path merges instead of replacing and so could never flip a deny back to allow. Tests — adds the missing call-site coverage. Deleting the `tools:` block from `routeMessage` previously left every test passing; `agent-tool-gate-wiring.test.ts` now fails without it, and covers the model-switch sequence that justifies sending the complete map. Drops a tautological `Object.keys` assertion that could only fail when a stronger assertion in the same file already had. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Adversarial review found the previous approach did not fix the reported bug.
Vertex rejects a request because it carries function declarations at all, not
because it carries `openchamber`. Denying one tool removed one declaration and
left the ~11 stock built-ins in place, so the send still failed. My own
end-to-end capture showed this and I misread it: I asserted `openchamber` was
absent from `functionDeclarations` and treated that as success, when the field
was still present with `bash, edit, glob, grep, question, read, skill, task,
todowrite, webfetch, write`. Agent `permission` config cannot close the gap
either — stock agents begin with `"*": "allow"`.
The gate now sends `{"*": false}` for models that cannot call tools, which drops
the `tools` field from the provider request entirely, and `{"*": true}`
otherwise. Verified by request capture against the real generated plugin: 0
declarations when denied, all 12 restored on the next send when allowed, in the
same session.
Because the gate now governs every tool rather than OpenChamber's own, it no
longer depends on whether this module injected anything, so it is correct in
runtimes where the control tool is absent. That also removes the cross-package
duplication of the tool name — `OPENCHAMBER_AGENT_TOOL_NAME` is now only used
inside the server module that owns the plugin.
Documented, all verified against opencode 1.18.15:
- This is a client-side mitigation for the paths OpenChamber controls, not a
complete fix. Server-side dispatchers (scheduled tasks, goal continuations,
obligatory context) post `prompt_async` directly and stay ungated, though they
inherit whatever ruleset the last gated send persisted.
- The complete fix belongs upstream: OpenCode has `capabilities.toolcall` and
never reads it in the request path.
- `session.update` does accept `permission` (contrary to my earlier claim), but
it merges rather than replaces, so it could never flip a deny back to allow.
The deprecated `tools` field is the only lever that self-corrects.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…annot work
Review found no safe client-side fix exists, so the gate is removed and the
findings are documented instead. What remains is the module doc plus an exported
tool-name constant used by the plugin generator.
The lever OpenCode exposes is the per-send `tools` map, which it converts into
the session permission ruleset and persists, replacing prior contents. Every
variant fails, each verified against opencode 1.18.15 by request capture:
- `{"openchamber": false}` — the original approach — removes one declaration
while ~11 stock built-ins still reach the provider. Vertex rejects on the
presence of any function declaration, so the send still fails. My earlier
end-to-end check asserted only that `openchamber` was absent and I read that
as success; the captured request still carried `tools` with 11 entries.
- `{"*": false}` does suppress the whole set (0 declarations, confirmed), but the
deny persists: a later send on a tool-calling model in the same session also
gets no tools.
- `{"*": true}` to recover is a privilege escalation, not a no-op. Permission
rules are evaluated for approval as well as for tool filtering
(`permission/index.ts` returns early on `action === "allow"`), so a wildcard
allow converts an agent's `edit: deny` into `allow` and `bash: ask` into
`allow`, suppressing the approval prompt. Confirmed by simulating the real
evaluator against a restrictive agent. Suppressing tools must never widen
permissions.
- `session.update` does accept `permission` (contrary to my earlier claim) but
merges rather than replaces, so it cannot clear a deny either.
`session.command` and `session.shell` could not have been covered regardless —
the OpenCode API has no `tools` field on either route.
The fix belongs upstream, where the capability is already known: return no tools
when `!model.capabilities.toolcall`. That covers every send path for every client
with no permission side effects.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Points at anomalyco/opencode#41463 and issue #41464 so a future maintainer can tell whether the constraint still applies, and notes that the section becomes history once a release carrying the fix ships. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Image generation needs two OpenCode fixes, not one: the toolcall gate (#41463) lets the request through, and a `case "file"` in SessionProcessor makes the result visible. Fixing only the first yields a billed request whose image is silently dropped. Also records that OpenChamber needs no change to display generated images — the receive path is type-agnostic — while flagging that the render leg is a code read rather than an executed test, since there is no component-render harness. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The image-drop bug now has a dev-targeted PR (anomalyco/opencode#41468, issue #41467) rather than only the v2-branch one, so both halves of the upstream fix are traceable from here. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
Note for anyone reading the automated review above: it reviewed Its findings were the reason for the revert, and independent review found two further problems it did not catch:
Since suppressing tools must never widen permissions, and |
Intent
Report a real user-facing bug and document why it cannot be fixed from OpenChamber.
OpenChamber injects its
openchambercontrol tool into every managed OpenCode process, at process start, before any model is known. When the user selects a model that cannot call tools, Vertex Gemini image models ("Nano Banana",gemini-2.5-flash-image) reject every send:The injected tool makes this unavoidable through configuration. Stock agents begin with
"*": "allow", and a user who denies every tool by name still cannot denyopenchamber, because nothing in the UI reveals it exists. The only working workaround is a hand-written agent withpermission: {"*": deny}— not discoverable.What changed
Documentation only, plus an exported constant so the plugin generator stops using a bare literal for its own tool ID.
Net diff versus the base:
+40 / -2across 3 files, all inpackages/web/server/lib/agent-tool/.Non-goals
6b8f8e69.Why there is no safe client-side fix
The only lever OpenCode exposes is the per-send
toolsmap. It converts that map into the session permission ruleset and persists it, replacing prior contents. Every variant fails — each verified againstopencode1.18.15 by capturing the outgoing provider request:{"openchamber": false}{"*": false}{"*": true}to recoveredit: denyintoallowandbash: askintoallow, suppressing the approval prompt.session.update({permission})Suppressing tools must never widen permissions, which rules out the only recovery path.
session.commandandsession.shellcould not have been covered regardless — the OpenCode API has notoolsfield on either route.The fix, upstream
Submitted as anomalyco/opencode#41463 against
dev(issue #41464). One guard inresolveTools:capabilities.toolcallis populated from provider config and never read in OpenCode's request path. This covers every send path for every OpenCode client, with no permission side effects. Verified there by request capture: a Vertex image model sends notoolsfield (was 12 declarations), a tool-calling model still sends all 12.Once a release carrying it ships, image models work in OpenChamber with any agent and no configuration, and this PR's documentation becomes history rather than an active constraint.
Follow-up once the upstream fixes land
Two separate OpenCode bugs sit between a user and a working image generation. Both are upstream; neither needs OpenChamber code.
dev(issue #41464)SessionProcessorhas nocase "file", so GeminiinlineDatanever becomes a message partdev(issue #41467). #40126 coversv2, which does not reachdevreleasesThey are sequential, not alternatives: #1 lets the request through, #2 makes the result visible. Fixing only #1 yields a successful call whose image is silently dropped — the user is billed for output they never see. Fixing only #2 changes nothing, because the request still 400s.
What OpenChamber has to do
Nothing, as far as I can determine. I traced the receive path in this worktree:
event-reducer.ts:19—SKIP_PARTSispatch,step-start,step-finish.fileis not skipped.event-reducer.ts:391— themessage.part.updatedreducer is type-agnostic; it upserts any part by ID.partUtils.ts:36—filterVisiblePartsfilters synthetic text and reasoning, neverfile.MessageBody.tsx:2230—AssistantMessageBodyalready renders<MessageFilesDisplay files={parts} />, andFileAttachment.tsx:580filters only ontype === 'file' && (mime || url)with no role check.So an assistant-side image part should render through the existing attachment display, the same way a user-attached image does today.
Unverified: this repo has no component-render harness for
MessageBody, so the render leg is a code read rather than an executed test. The first real check is a live send once a release carries #41463 — if the image arrives as a part but does not display, the gap is in the render path above and that is the OpenChamber fix to write.Order of work
buildagent and no configuration.SessionProcessorgains acase "file"that persists the media as afilepart, plus assistant-media replay so follow-up edits ("make it brighter") work.Until step 1 ships, the only user-side workaround remains an agent with
permission: {"*": deny}— see the doc section this PR adds.Affected surfaces
packages/web/server/lib/agent-tool/OPENCHAMBER_AGENT_TOOL_NAME, interpolated into the generated plugin instead of a bare literalRepository guidance
AGENTS.md→ "Enforce security and correctness in core/runtime logic"AGENTS.md→ "Update owning documentation when contracts or invariants change"DOCUMENTATION.mdopenchamber-change-discipline→ smallest complete changeopenchamber-change-discipline→ "Do not hide a required architectural migration behind a local heuristic"Validation
bun run type-check(all 5 workspaces, incl. both VS Code projects)bun run lint(all 5 workspaces)bunx vitest run server/lib/agent-tool/runtime.test.jsbun run docs:validateNot verified: live Vertex traffic. The provider endpoint was a local recording proxy, so the 400 is reproduced by request shape rather than by Google's response. The permission-escalation finding was verified by simulating OpenCode's own evaluator against a restrictive agent, not by observing a suppressed prompt in the UI.
Visual evidence
None applicable — no rendered output, copy, or interaction changes.
Risks and failure behavior
The documented bug is unfixed and still reachable: any send to a non-tool-calling model fails until the upstream change lands, and the only user workaround remains a hand-written wildcard-deny agent.
The doc change carries no runtime risk. The exported constant is consumed only inside its own module and its test.
Corrections to earlier revisions of this PR
Stated as fact earlier and wrong:
toolsdoes not filter one request." It does both — it is stored on the user message and filters that request, and replaces the persisted ruleset.session.updateacceptspermission; I read the wrong SDK path.packages/ui/srcand uses the same send path, so the gate would have run there against an OpenCode that never had the tool injected.