From 6b1f2d71de66f78e9134b3f6b8c2f172661fdc28 Mon Sep 17 00:00:00 2001 From: LantisPrime Date: Sun, 28 Jun 2026 13:55:48 +0800 Subject: [PATCH] fix(opencode): spawn real node for the bridge, not process.execPath (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) --- plugins/opencode/capabilities/enforcement.ts | 33 ++++- tests/test-opencode-enforcement-live-e2e.mjs | 128 +++++++++++++++++++ 2 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 tests/test-opencode-enforcement-live-e2e.mjs diff --git a/plugins/opencode/capabilities/enforcement.ts b/plugins/opencode/capabilities/enforcement.ts index 2b56a3c..32dea4d 100644 --- a/plugins/opencode/capabilities/enforcement.ts +++ b/plugins/opencode/capabilities/enforcement.ts @@ -33,13 +33,40 @@ // @ts-ignore — type-only; not compiled in this repo (zero-dep policy for test harness) import type { Plugin } from "@opencode-ai/plugin"; import { spawnSync } from "node:child_process"; -import { realpathSync } from "node:fs"; +import { realpathSync, existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; +import { dirname, resolve, basename } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const BRIDGE = resolve(__dirname, "enforce-bridge.mjs"); +// Resolve a real `node` to run the .mjs bridge. CRITICAL: under opencode's +// Bun-compiled runtime, process.execPath is the OPENCODE binary, NOT node — so +// spawnSync(process.execPath, [BRIDGE]) makes opencode treat the .mjs path as a +// project dir, fail to cd, exit 0 with EMPTY stdout, and the JSON.parse below +// throws -> fail-closed on EVERY gated tool call (the bridge never runs). Use +// process.execPath only when it IS node; otherwise locate node on PATH. Bug +// reproduced 2026-06-28 by driving the live opencode TUI (the bridge-proxy / +// direct-node tests masked it). Regression: tests/test-opencode-node-resolve.mjs. +// Exported + parameterized for the regression test: a node-run test can't change +// its own process.execPath, so it passes a simulated non-node exe to prove the +// resolution. The default (no-arg) path is cached per process. +let _nodeExe: string | null = null; +export function resolveNodeExe(exe: string = process.execPath): string { + const useCache = exe === process.execPath; + if (useCache && _nodeExe) return _nodeExe; + let result: string; + if (/^node(\.exe)?$/i.test(basename(exe))) { + result = exe; + } else { + const finder = process.platform === "win32" ? "where" : "which"; + const w = spawnSync(finder, ["node"], { encoding: "utf8" }); + result = (w.stdout || "").split(/\r?\n/).map((s) => s.trim()).find((s) => s && existsSync(s)) || "node"; + } + if (useCache) _nodeExe = result; + return result; +} + // Per-session turn_index counters. Incremented synchronously BEFORE any await // (EC3: no shared mutable state that a concurrent call could race on). const _turnIndex = new Map(); @@ -53,7 +80,7 @@ function nextTurnIndex(sessionId: string): number { /** Spawn the bridge, return parsed stdout decision. Throws on any error. */ function callBridge(envelope: Record): { action: string; reason: string; label: string | null; effective_tier: string | null } { const input = JSON.stringify(envelope); - const r = spawnSync(process.execPath, [BRIDGE], { + const r = spawnSync(resolveNodeExe(), [BRIDGE], { input, encoding: "utf8", timeout: 10000, diff --git a/tests/test-opencode-enforcement-live-e2e.mjs b/tests/test-opencode-enforcement-live-e2e.mjs new file mode 100644 index 0000000..abbe393 --- /dev/null +++ b/tests/test-opencode-enforcement-live-e2e.mjs @@ -0,0 +1,128 @@ +/** + * test-opencode-enforcement-live-e2e.mjs — ACTUAL-TERMINAL regression test. + * + * BUG (reproduced 2026-06-28 by driving the live opencode TUI): + * plugins/opencode/capabilities/enforcement.ts spawned the bridge with + * `spawnSync(process.execPath, [BRIDGE])`. Under opencode's Bun-compiled + * runtime, process.execPath is the OPENCODE binary (not node). opencode then + * treats the .mjs path as a project dir, fails to cd, exits 0 with EMPTY + * stdout → the adapter's JSON.parse throws → "fail-closed: bridge stdout not + * valid JSON" on EVERY gated tool call. The enforcement never actually decides. + * + * WHY PROXY TESTS MISSED IT (and why this test exists): + * test-opencode-adapter-conformance.mjs imports the adapter UNDER NODE, where + * process.execPath IS node, so the broken spawn worked and every suite stayed + * green. test-opencode-adapter.mjs drives enforce-bridge.mjs directly under + * node — same blind spot. The bug only manifests in the REAL opencode/Bun + * runtime. This test drives the actual opencode binary in a real terminal + * (tmux) — NO node proxy — and asserts the hook DECIDES instead of fail-closing. + * + * FIX: resolveNodeExe() in enforcement.ts — use process.execPath only when it IS + * node, else locate node on PATH. + * + * PRECONDITIONS (real binary + TTY + a reachable model): + * - opencode CLI installed (~/.opencode/bin/opencode or on PATH) + * - a configured + REACHABLE opencode model (this is an agent-driven E2E) + * - tmux >= 3.5 + * Missing any precondition → SKIP (exit 0 with a SKIP line), never a false pass. + * UNGUARDED-IN-CI: real-runtime, model-dependent — run locally/manually. + * + * Run: node tests/test-opencode-enforcement-live-e2e.mjs + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFileSync, spawnSync } from "node:child_process"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO = fs.realpathSync(path.join(__dirname, "..")); +const INSTALL = path.join(REPO, "install.mjs"); +const SOCK = `oc-e2e-${process.pid}`; +const SESS = "oc"; + +function skip(msg) { console.log(`SKIP: ${msg}`); process.exit(0); } +function tmux(args, opts = {}) { + return spawnSync("tmux", ["-L", SOCK, ...args], { encoding: "utf8", timeout: 30000, ...opts }); +} +function pane(lines = 250) { + const r = tmux(["capture-pane", "-p", "-e", "-J", "-t", SESS, "-S", `-${lines}`]); + return (r.stdout || "").replace(/\x1b\[[0-9;]*m/g, ""); +} +const _sab = new Int32Array(new SharedArrayBuffer(4)); +function sleep(ms) { Atomics.wait(_sab, 0, 0, ms); } // real blocking sleep, no CPU spin, cross-platform + +// --- preconditions ----------------------------------------------------------- +let OC = ""; +try { OC = execFileSync("bash", ["-lc", "command -v opencode || echo ${HOME}/.opencode/bin/opencode"], { encoding: "utf8" }).trim(); } catch { /* */ } +if (!OC || !fs.existsSync(OC)) skip("opencode binary not found"); +if (tmux(["-V"]).status !== 0) skip("tmux not available"); + +// --- mock project + enforcement install -------------------------------------- +const mock = fs.mkdtempSync(path.join(os.tmpdir(), "oc-e2e-")); +fs.mkdirSync(path.join(mock, "src")); +fs.writeFileSync(path.join(mock, "src", "probe.mjs"), "export const x = 1;\n"); +execFileSync("git", ["init", "-q"], { cwd: mock }); +const install = spawnSync(process.execPath, [INSTALL, "--tool", "opencode", "--project", mock, "--install-enforcement"], { encoding: "utf8", timeout: 120000 }); +if (install.status !== 0) { cleanup(); console.error(`FAIL: install exit ${install.status}: ${(install.stderr || "").slice(0, 300)}`); process.exit(1); } + +// --- drive the real opencode TUI --------------------------------------------- +let passed = false, reason = ""; +try { + tmux(["kill-server"]); + const conf = path.join(mock, ".tmuxconf"); + fs.writeFileSync(conf, "set -g extended-keys-format csi-u\nset -g extended-keys on\n"); + tmux(["-f", conf, "new-session", "-d", "-s", SESS, "-x", "220", "-y", "50"]); + sleep(800); + tmux(["send-keys", "-t", SESS, `cd ${mock}`, "Enter"]); sleep(500); + tmux(["send-keys", "-t", SESS, OC, "Enter"]); sleep(12000); + + // Send a gated bash write through the agent. + const prompt = "Use your bash tool to run exactly this and report success or hook-block: echo REGRESSION_OK > src/probe.mjs"; + const buf = path.join(mock, ".prompt"); + fs.writeFileSync(buf, prompt); + tmux(["load-buffer", "-t", SESS, buf]); + tmux(["paste-buffer", "-t", SESS]); sleep(1500); + tmux(["send-keys", "-t", SESS, "Enter"]); sleep(3000); + + // Wait for either the model-unreachable error, a permission prompt, or completion. + let connErr = false; + for (let i = 0; i < 48; i++) { + const p = pane(12); + if (/Cannot connect to API|Unable to connect/i.test(p)) { connErr = true; break; } + if (/Permission required/i.test(p)) break; // hook ALLOWED → opencode's own permission gate + if (/fail-closed|not valid JSON/i.test(p)) break; // the BUG signature + if (!/esc to interrupt|esc interrupt|working|thinking/i.test(p) && i > 3) break; + sleep(5000); + } + if (connErr) { cleanup(); skip("opencode model unreachable (start LM Studio / configure a reachable model)"); } + + // Approve any permission prompt (Allow once = default), then let it run. + if (/Permission required/i.test(pane(12))) { tmux(["send-keys", "-t", SESS, "Enter"]); } + for (let i = 0; i < 24; i++) { if (!/esc to interrupt|esc interrupt|working|thinking/i.test(pane(12))) break; sleep(5000); } + sleep(2000); + + const full = pane(300); + const fileContent = fs.readFileSync(path.join(mock, "src", "probe.mjs"), "utf8"); + + // REGRESSION ASSERTIONS: + // 1. the bug signature must NOT appear (no fail-closed on a decidable command) + // 2. the hook ALLOWED the echo (read_only) → the write landed on disk + const noFailClosed = !/fail-closed|bridge stdout not valid JSON/i.test(full); + const wrote = /REGRESSION_OK/.test(fileContent); + passed = noFailClosed && wrote; + reason = `noFailClosed=${noFailClosed} wrote=${wrote} (file="${fileContent.trim()}")`; +} finally { + tmux(["kill-server"]); +} + +cleanup(); +if (passed) { console.log(`PASS: live opencode hook decided (no fail-close) and write landed — ${reason}`); process.exit(0); } +console.error(`FAIL: ${reason}`); +process.exit(1); + +function cleanup() { + try { tmux(["kill-server"]); } catch { /* */ } + try { fs.rmSync(mock, { recursive: true, force: true }); } catch { /* */ } +}