Skip to content

fix(codemode): abort on permission decline, bound execution with host limits - #348

Merged
shuv1337 merged 3 commits into
integration-v2from
shuvbotta/codemode-fixes
Jul 28, 2026
Merged

fix(codemode): abort on permission decline, bound execution with host limits#348
shuv1337 merged 3 commits into
integration-v2from
shuvbotta/codemode-fixes

Conversation

@shuv1337

Copy link
Copy Markdown
Collaborator

Two Code Mode fixes found while building an external gateway (shuvbotta) against this server. Both are independent; fix 2 does not depend on fix 1. Written to be PR-able upstream unchanged.

Fix 1 — fix(codemode): let a permission decline abort the step

Problem. Declining a permission from inside a Code Mode program does not abort the step. The model receives a generic ToolFailure reading Tool execution failed and keeps going, and the program can even catch it — while the same decline on a direct tool call aborts the step.

Declines travel as Effect defects deliberately. Permission.assert calls it a "Deliberate defect tunnel" (packages/core/src/permission.ts), because tool leaves wrap execution in a blanket mapError that must not convert a user's "no" into model-facing tool output; it is consumed once at SessionModelRequest.executeTool. The Code Mode tool runtime closed that tunnel: runHost in packages/codemode/src/tool-runtime.ts caught every non-interrupt cause and rewrote it to toolError("Tool execution failed", error). A second layer in interpreter/execute.ts then turns any non-interrupt cause into an ok: false result.

Change. @opencode-ai/codemode has no business knowing what a permission is, so the classifier comes from the host: a new optional tunnelDefect?: (defect: unknown) => boolean on CodeMode options. Absent (the default) is behaviourally identical to today — every defect is still sanitized, and the existing "sanitizes unknown host failures and defects" test is untouched.

A matching defect is recorded and converted to Effect.interrupt, then re-raised as a defect at the execution boundary. Interruption is the one channel the interpreter already treats as uncatchable — evaluateTryStatement rethrows on Cause.isInterruptReason, and every promise seam re-fails on Cause.hasInterruptsOnly rather than converting to a catchable JS error — so a decline gets exactly the semantics it should have without teaching ~8 seams about a second special cause. The boundary check runs on the success path too, since an un-awaited declined call leaves the program itself succeeding.

Core passes Decline.is, a new single definition of what counts as a tunneled decline (Permission.DeclinedError | QuestionTool.CancelledError), now also used by model-request.ts so the two consumers cannot drift.

Fix 2 — fix(codemode): bound Code Mode execution with host limits

Problem. CodeModeTool.create builds the runtime with no limits, and @opencode-ai/codemode defaults timeoutMs, maxToolCalls and maxOutputBytes to unlimited (documented in its README). Code Mode interprets model-authored TypeScript in-process, so today a while (true) {} hangs the session indefinitely, tool fan-out is unbounded, and an arbitrarily large return value is carried whole.

Change. Conservative host defaults, overridable through a new codemode config key:

Default Config key What it governs
timeoutMs 120000 codemode.timeout_ms Whole program; interrupts busy loops and in-flight calls. A program that already returned still succeeds, with a TimeoutExceeded warning.
maxToolCalls 100 codemode.max_tool_calls Call admission, including the built-in search(). Breach is a model-facing ToolCallLimitExceeded diagnostic, so the model can re-plan in batches.
maxOutputBytes 1 MiB codemode.max_output_bytes Retained result value and logs (warnings get a separate equal budget). Truncation does not fail execution.

These are ceilings, not budgets — ordinary programs never reach them.

The Tool service reads the merged codemode blocks the same way Image reads attachments.image, so Tool.node gains Config.node as a declared dep. That edge previously existed only transitively through Image.node, which ten test files stub out; each gets one [Config.node, emptyConfigLayer] binding using the existing fixture. That also keeps those tests hermetic — otherwise the real config layer boots and reads the developer's ~/.config/opencode.

Tests

Fix 1 — packages/codemode: tunneled defect is re-raised; program try/catch cannot swallow it; an un-awaited declined call still aborts; unmatched defects are still sanitized and still leak nothing. packages/core: end-to-end — a registered tool that dies with Permission.DeclinedError (the shape Permission.assert produces), executed through the real Code Mode execute tool, surfaces as a DeclinedError die reason rather than a completed result.

Fix 2 — new CodeMode execution limits suite: the default tool-call ceiling stops an unbounded fan-out at exactly DEFAULT_LIMITS.maxToolCalls; configured max_tool_calls, max_output_bytes and timeout_ms each take effect (the timeout case is a real while (true) {} on the live clock).

All eight new tests fail without their respective change — the busy-loop case simply runs until the test runner's own timeout. bun test green in both packages (1093 and 1458 passing), tsgo --noEmit clean.

shuv1337 added 2 commits July 28, 2026 04:36
A user declining a permission from inside a Code Mode program produced a
generic "Tool execution failed" ToolFailure and the model kept going, while
the same decline on a direct tool call aborts the step.

Declines travel as Effect defects on purpose (the tunnel entered in
Permission.assert, consumed at SessionModelRequest.executeTool) so a blanket
mapError cannot turn a user's "no" into model-facing tool output. The Code
Mode tool runtime caught every non-interrupt cause and rewrote it, which
closed that tunnel.

Adds an opt-in `tunnelDefect` predicate to CodeMode options. A matching
defect rides the existing interrupt path — uncatchable by program
try/catch, like a direct call's decline — and is re-raised unchanged at the
execution boundary, including when the declined call was never awaited.
Unmatched defects are still sanitized. The host passes Decline.is, a single
shared definition of what counts as a decline, now also used by
model-request.
CodeModeTool.create built the runtime with no `limits`, and
@opencode-ai/codemode defaults timeoutMs, maxToolCalls and maxOutputBytes to
unlimited. A model-authored busy loop, an unbounded fan-out over tools, or an
oversized return value therefore had no ceiling at all.

Applies conservative host defaults — 120s, 100 tool calls, 1 MiB of retained
result and logs — overridable per config through a new `codemode` key. These
are ceilings, not budgets: ordinary programs never reach them, and each
breach produces a model-facing diagnostic the model can act on rather than a
silent hang.

Tool.node now depends on Config.node to read them; test fixtures that stub
Image.node (which previously pulled Config in transitively) bind an empty
config layer.
@blacksmith-sh

blacksmith-sh Bot commented Jul 28, 2026

Copy link
Copy Markdown

Found 2 test failures on Blacksmith runners:

Failures

Test View Logs
e2e/regression/review-terminal-stacked.spec.ts/
keeps the review tree and terminal sized when both panels are open
View Logs
smoke: session timeline/renders seeded timeline in order while paging through history View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@shuv1337
shuv1337 merged commit 5827635 into integration-v2 Jul 28, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant