Skip to content

fix(opencode): spawn real node for the bridge, not process.execPath (Bun-runtime fail-close)#427

Merged
lantiscooperdev merged 1 commit into
mainfrom
fix/opencode-enforcement-execpath-bun-runtime
Jun 28, 2026
Merged

fix(opencode): spawn real node for the bridge, not process.execPath (Bun-runtime fail-close)#427
lantiscooperdev merged 1 commit into
mainfrom
fix/opencode-enforcement-execpath-bun-runtime

Conversation

@lantisprime

Copy link
Copy Markdown
Owner

What

Fix the opencode enforcement adapter fail-closing on every gated tool call in the real opencode runtime.

The bug

plugins/opencode/capabilities/enforcement.ts spawned the node bridge with spawnSync(process.execPath, [BRIDGE]). Under opencode's Bun-compiled runtime, process.execPath is the opencode binary, not node. opencode treats the .mjs path as a project directory, fails to cd into it, exits 0 with empty stdout → the adapter's JSON.parse throws → "opencode-enforce: fail-closed: bridge stdout not valid JSON" on every gated tool call. The enforcement never actually decides; it blocks everything (the agent cannot run bash or write files at all).

Why it shipped green (#424)

Every existing test is a proxy that runs under node, where process.execPath IS node:

  • test-opencode-adapter-conformance.mjs imports the adapter under node.
  • test-opencode-adapter.mjs drives the bridge directly under node.

The bug only manifests in the real opencode/Bun runtime — the recurring "real-runtime E2E was actually a bridge proxy" gap.

Root cause (empirically reproduced)

Driving the real opencode binary against the deployed bridge:

spawnSync("~/.opencode/bin/opencode", [enforce-bridge.mjs], {input})
  → status: 0, stdout: "",
    stderr: "Error: Failed to change directory to .../enforce-bridge.mjs"

Fix

resolveNodeExe() — use process.execPath only when its basename is node; otherwise locate node on PATH (which/where), with a "node" last resort. Spawn the resolved node for the bridge.

Regression test (actual terminal, not proxy)

tests/test-opencode-enforcement-live-e2e.mjs drives the actual opencode binary via tmux against a mock project with enforcement installed, then asserts the hook decides (no fail-close) and the write lands. It guards the exact fail-closed|not valid JSON signature, so it goes red if anyone reverts to process.execPath.

Observed both states this change:

  • Before fix: live opencode agent → fail-closed → write blocked.
  • After fix: hook decides (allows echo, read_only) → opencode's own permission prompt → write lands (src/x.mjs = BYPASS_OK). Automated test: PASS (noFailClosed=true, wrote=true).

UNGUARDED-IN-CI (needs the real binary + a reachable model + a TTY).

Deploy

Post-merge, opencode-enforcement projects need a re-install to pick up the fixed adapter:
node install.mjs --tool opencode --install-enforcement --project <dir>.

🤖 Generated with Claude Code

…Bun-runtime fail-close)

Under opencode's Bun-compiled runtime, process.execPath is the opencode binary,
not node. spawnSync(process.execPath, [bridge.mjs]) made opencode treat the .mjs
path as a project dir, fail to cd, exit 0 with EMPTY stdout -> the adapter
JSON.parse threw -> 'fail-closed: bridge stdout not valid JSON' on EVERY gated
tool call. Enforcement never actually decided; it blocked everything.

Root cause confirmed empirically by driving the live opencode TUI. The
bridge-proxy and node-import conformance tests masked it: they run under node,
where process.execPath IS node. Fix: resolveNodeExe() uses process.execPath only
when it is node, else locates node on PATH.

Regression: tests/test-opencode-enforcement-live-e2e.mjs drives the ACTUAL
opencode binary via tmux and asserts the hook decides (no fail-close) and the
write lands. UNGUARDED-IN-CI (real binary + reachable model + TTY).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@lantiscooperdev lantiscooperdev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot code-review pass

Scope: the 2-file diff — enforcement.ts (resolveNodeExe) + tests/test-opencode-enforcement-live-e2e.mjs.

Correctness

  • ACCEPTresolveNodeExe gates on basename(exe) matching ^node(\.exe)?$, else resolves node via which/where, with a "node" last resort. This addresses the confirmed root cause (process.execPath is the opencode binary, not node, under Bun). The live E2E goes green and the manual before/after (fail-closed → decided + write landed) corroborates.
  • The per-process cache only short-circuits the default process.execPath path; explicit-arg calls bypass it, which keeps the function testable. Good.

Residuals (non-blocking, documentation-tier)

  1. PATH dependency: if node is not on opencode's spawn PATH, the "node" last-resort ENOENTs → r.error → throw → fail-closed. Safe (blocks, never opens), but "enforcement actually decides" now requires node on PATH. Worth a one-line runbook note.
  2. Version skew: which node takes the first PATH node, which may differ from a project's intended toolchain node. Low risk for a zero-dep stdlib bridge.
  3. Test approval keypress: the E2E sends Enter assuming "Allow once" is the default-highlighted permission option. If opencode reorders that menu, the keypress could land elsewhere — but the wrote=true assertion fails-closed on a wrong approval, so the test stays sound.

Tests

  • The actual-terminal E2E is the correct call: the existing test-opencode-adapter-conformance.mjs / -adapter.mjs run under node and structurally cannot reproduce this class. SKIP-on-missing-precondition (no binary / model / tmux) is honest, not a false pass.

Verdict: ACCEPT. Real shipped bug, correct minimal fix, real-runtime regression guard. Residuals are doc-tier, not blockers. User approval in the UI satisfies the required-review gate (Rule 17).

@lantiscooperdev lantiscooperdev merged commit bb01c32 into main Jun 28, 2026
3 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.

2 participants