diff --git a/agent-network/bin/cli.ts b/agent-network/bin/cli.ts index eb9970c8..07575b3c 100644 --- a/agent-network/bin/cli.ts +++ b/agent-network/bin/cli.ts @@ -10,8 +10,8 @@ * anet run 独立 SSE Agent */ -import { chmodSync, readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, renameSync, rmSync, cpSync } from "fs"; -import { dirname, isAbsolute, join } from "path"; +import { chmodSync, readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, lstatSync, renameSync, rmSync, cpSync } from "fs"; +import { dirname, isAbsolute, join, resolve } from "path"; import { fileURLToPath } from "url"; import { homedir } from "os"; import { spawn, execSync, execFileSync } from "child_process"; @@ -30,6 +30,10 @@ import { validateAgentNodePackageEntrypoint, } from "../src/opencode-agent-node-pair"; import { hardenOpencodeAgentNodeEnv } from "../src/opencode-launch-env"; +import { + signalExactProcessGracefully, + stopExactProcessTermOnly, +} from "../src/opencode-wrapper-stop"; import { clearOpencodeAuthJson, findOpencodePreset, @@ -3412,27 +3416,61 @@ async function launchAgent(id: string, forceNewSession = false) { const RESTART_SENTINEL = 75; let lastNonRestartCode: number | null = null; let activeAgentChild: ReturnType | null = null; + let activeAgentChildIdentity: BoundOpencodeProcessIdentity | null = null; let parentShuttingDown = false; - let childKillTimer: ReturnType | null = null; + const childGracefulStops = new Set>(); // The foreground anet process owns the supervised OpenCode child. Keep // this handler OpenCode-only: generic Windows runtimes launch through a // cmd.exe wrapper (`shell:true`) and must retain main's already-vetted // process lifecycle until a process-tree-aware Windows gate exists. - const forwardAgentSignal = (signal: NodeJS.Signals) => { - if (parentShuttingDown) return; + const forwardAgentSignal = (signal: "SIGINT" | "SIGTERM") => { parentShuttingDown = true; - try { activeAgentChild?.kill(signal); } catch {} - childKillTimer = setTimeout(() => { - try { activeAgentChild?.kill("SIGKILL"); } catch {} - }, 5_000); - childKillTimer.unref?.(); + const child = activeAgentChild; + if (!child?.pid) return; + const pid = child.pid; + const identity = activeAgentChildIdentity; + const attempt = signalExactProcessGracefully({ + pid, + // Bind the live ChildProcess handle to Linux starttime. The handle + // alone still stores a numeric PID and is not a pidfd. + readState: () => { + if (child.exitCode !== null || child.signalCode !== null) return "exited-or-reused"; + if (activeAgentChild !== child || !identity) return "unknown"; + const currentStartTime = readProcStartTime(identity.pid); + if (!currentStartTime) return pidAlive(identity.pid) ? "unknown" : "exited-or-reused"; + return currentStartTime === identity.startTime ? "same" : "exited-or-reused"; + }, + // agent-node may need to drain supervisor TERM + group reap + procfs + // verification. Keep the wrapper alive beyond that whole budget. + timeoutMs: 10_000, + pollMs: 100, + }, signal).then((exited) => { + if (!exited) { + // SIGKILLing only the wrapper would orphan its detached ACP group. + // Retain the owner tree for inspection/retry and make any eventual + // launcher exit non-zero rather than claiming a clean stop. + process.exitCode = 1; + console.error( + `[anet] OpenCode agent-node pid ${pid} did not exit after ${signal}; ` + + `wrapper retained so its detached ACP process group is not orphaned`, + ); + } + return exited; + }).finally(() => { + childGracefulStops.delete(attempt); + }); + childGracefulStops.add(attempt); }; const onAgentSigint = () => forwardAgentSignal("SIGINT"); const onAgentSigterm = () => forwardAgentSignal("SIGTERM"); + const onAgentSighup = () => forwardAgentSignal("SIGTERM"); if (runtime === "opencode-cli") { - process.once("SIGINT", onAgentSigint); - process.once("SIGTERM", onAgentSigterm); + // Persistent listeners make a second operator signal a retry, never the + // default action that would kill only the wrapper and orphan its ACP. + process.on("SIGINT", onAgentSigint); + process.on("SIGTERM", onAgentSigterm); + process.on("SIGHUP", onAgentSighup); } await superviseChild({ @@ -3471,7 +3509,13 @@ async function launchAgent(id: string, forceNewSession = false) { stdio: "inherit", shell: runtime === "opencode-cli" ? false : process.platform === "win32", }); - if (runtime === "opencode-cli") activeAgentChild = child; + if (runtime === "opencode-cli") { + activeAgentChild = child; + const startTime = child.pid ? readProcStartTime(child.pid) : undefined; + activeAgentChildIdentity = child.pid && startTime + ? { pid: child.pid, startTime } + : null; + } if (child.pid) writeFileSync(pidFile, String(child.pid)); let settled = false; @@ -3492,6 +3536,7 @@ async function launchAgent(id: string, forceNewSession = false) { clearTimeout(stableTimer); if (runtime === "opencode-cli" && activeAgentChild === child) { activeAgentChild = null; + activeAgentChildIdentity = null; } // Always remove the .pid before deciding the next step — the @@ -3511,10 +3556,11 @@ async function launchAgent(id: string, forceNewSession = false) { lastNonRestartCode = exitInfo.code ?? 0; }, }); - if (childKillTimer) clearTimeout(childKillTimer); + if (childGracefulStops.size > 0) await Promise.all([...childGracefulStops]); if (runtime === "opencode-cli") { process.off("SIGINT", onAgentSigint); process.off("SIGTERM", onAgentSigterm); + process.off("SIGHUP", onAgentSighup); } // If the child exited with a non-zero, non-sentinel code, propagate @@ -5099,6 +5145,102 @@ function findNodeProcessesByAlias(...aliases: string[]): number[] | null { return [...pids]; } +interface BoundOpencodeProcessIdentity { + pid: number; + /** Linux procfs starttime (field 22), stable across PID reuse. */ + startTime: string; +} + +function readProcStartTime(pid: number): string | undefined { + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const commEnd = stat.lastIndexOf(")"); + if (commEnd < 0) return undefined; + // The suffix starts at field 3 (state); starttime is field 22. + return stat.slice(commEnd + 2).trim().split(/\s+/)[19]; + } catch { + return undefined; + } +} + +function readBoundOpencodeProcessIdentity( + pid: number, + nodeId: string, + _profile: Profile, +): BoundOpencodeProcessIdentity | undefined { + if (process.platform !== "linux" || pid === process.pid) return undefined; + let argv: string[]; + try { + argv = readFileSync(`/proc/${pid}/cmdline`).toString("utf8").split("\0").filter(Boolean); + } catch { + return undefined; + } + const expectedConfig = resolve(join(nodesDir(), nodeId, "config.json")); + const isAgentNode = argv.some((value) => { + const base = value.split(/[\\/]/).pop() || value; + return base === "agent-node" + || value.includes("@sleep2agi/agent-node") + || /[\\/]agent-node[\\/](?:dist|src)[\\/]cli\.(?:js|ts)$/.test(value); + }); + if (!isAgentNode) return undefined; + const valueAfter = (flag: string): string | undefined => { + const index = argv.indexOf(flag); + return index >= 0 ? argv[index + 1] : undefined; + }; + const config = valueAfter("--config"); + if (!config || resolve(config) !== expectedConfig) return undefined; + if (valueAfter("--runtime") !== "opencode-cli") return undefined; + // Alias is mutable config/display metadata. Canonical config path + bound + // runtime identify ownership even if a profile rename raced the old wrapper. + const startTime = readProcStartTime(pid); + return startTime ? { pid, startTime } : undefined; +} + +function boundOpencodeProcessState( + expected: BoundOpencodeProcessIdentity, + nodeId: string, + profile: Profile, +): "same" | "exited-or-reused" | "unknown" { + const currentStartTime = readProcStartTime(expected.pid); + if (!currentStartTime) return pidAlive(expected.pid) ? "unknown" : "exited-or-reused"; + if (currentStartTime !== expected.startTime) return "exited-or-reused"; + const current = readBoundOpencodeProcessIdentity(expected.pid, nodeId, profile); + if (!current) return pidAlive(expected.pid) ? "unknown" : "exited-or-reused"; + return current.startTime === expected.startTime ? "same" : "exited-or-reused"; +} + +async function terminateBoundOpencodeProcess( + expected: BoundOpencodeProcessIdentity, + nodeId: string, + profile: Profile, +): Promise { + // Never SIGKILL only the wrapper: it owns a detached OpenCode ACP process + // group. If its graceful handler cannot run, retain the ownership tree and + // fail the stop instead of manufacturing an orphan. + return stopExactProcessTermOnly({ + pid: expected.pid, + readState: () => boundOpencodeProcessState(expected, nodeId, profile), + }); +} + +function findBoundOpencodeProcesses( + nodeId: string, + profile: Profile, +): BoundOpencodeProcessIdentity[] | null { + if (process.platform !== "linux") return null; + let entries: string[]; + try { entries = readdirSync("/proc").filter((name) => /^\d+$/.test(name)); } + catch { return null; } + const matches: BoundOpencodeProcessIdentity[] = []; + for (const entry of entries) { + const pid = Number(entry); + if (!Number.isInteger(pid)) continue; + const identity = readBoundOpencodeProcessIdentity(pid, nodeId, profile); + if (identity) matches.push(identity); + } + return matches; +} + // #146 GOTCHA-2 — best-effort drain before a rename restart kills the agent. // Polls commhub /api/status: returns true once the node is NOT actively // running a task (idle / blocked / error / offline / fell off status), false @@ -5223,6 +5365,11 @@ anet node rename [--force] throw new Error("external OpenCode binding resolved to a non-OpenCode runtime"); } boundRenameProfile = resolvedBound.profile; + } else if (normalizeRuntimeStrict(resolved.profile) === "opencode-cli") { + throw new Error( + `OpenCode runtime binding is missing for node ${JSON.stringify(oldId)}. ` + + `Refusing legacy/unproven state; recreate this preview node before renaming it.`, + ); } } catch (error: any) { console.error( @@ -5231,6 +5378,39 @@ anet node rename [--force] ); process.exit(1); } + if (boundRenameProfile) { + // Active bound OpenCode rename used to fall through to the legacy alias + // scan and its SIGKILL fallback. Killing only agent-node can orphan the + // detached ACP group, and alias metadata is not an ownership identity. + // Require the authoritative TERM-only stop command before rename mutates + // config, binding, lock, or CommHub state. + const liveBound = findBoundOpencodeProcesses(oldId, boundRenameProfile); + if (liveBound === null) { + console.error( + `[anet] Refusing to rename bound OpenCode node ${JSON.stringify(oldId)}: ` + + `cannot prove the exact wrapper is stopped; run 'anet node stop ${oldId}' first.`, + ); + process.exit(1); + } + const boundPidPath = join(oldDir, ".pid"); + let unauthenticatedLivePid: number | undefined; + if (liveBound.length === 0 && existsSync(boundPidPath)) { + const recordedPid = Number(readFileSync(boundPidPath, "utf8").trim()); + if (Number.isSafeInteger(recordedPid) && recordedPid > 0 && pidAlive(recordedPid)) { + unauthenticatedLivePid = recordedPid; + } + } + if (liveBound.length > 0 || unauthenticatedLivePid !== undefined) { + const detail = liveBound.length > 0 + ? `exact wrapper pid(s) ${liveBound.map(({ pid }) => pid).join(", ")} still live` + : `recorded pid ${unauthenticatedLivePid} is live but not authenticatable`; + console.error( + `[anet] Refusing to rename running bound OpenCode node ${JSON.stringify(oldId)}: ${detail}; ` + + `run 'anet node stop ${oldId}' first.`, + ); + process.exit(1); + } + } // state check: running node needs --force (RFC-010 §4.4 active rename). // #146 / #180 ship-blocker — DO NOT trust .pid for old-process identity. A // stale .pid (left by an agent that exited abnormally, its exit handler never @@ -5290,9 +5470,41 @@ anet node rename [--force] // ── PHASE 1: PREPARE (copy/prepare, old node untouched — fully rollbackable) ── writeFileSync(lockPath, JSON.stringify({ old: oldId, new: newName, phase: "prepare", ts: Date.now() }) + "\n"); let txnId: string | null = ""; + let preparedNewDirIdentity: { dev: number; ino: number } | undefined; + const ownsPreparedNewDir = (): boolean => { + if (!boundRenameProfile) return true; + if (!preparedNewDirIdentity) return false; + try { + const current = lstatSync(newDir); + return current.isDirectory() + && !current.isSymbolicLink() + && current.dev === preparedNewDirIdentity.dev + && current.ino === preparedNewDirIdentity.ino; + } catch { + return false; + } + }; try { // P2: copy (not move) old → new + update config.alias + if (boundRenameProfile) { + // fs.cpSync creates a recursive-copy destination through the caller's + // umask (typically 0755), even when the source node root is private. + // Establish and validate the exact destination as 0700 first so copied + // profile/auth state is never transiently exposed and saveProfile can + // create the replacement external binding without weakening its mode + // invariant. mkdir is the no-overwrite claim on the checked name. + mkdirSync(newDir, { mode: 0o700 }); + const created = lstatSync(newDir); + if (!created.isDirectory() || created.isSymbolicLink()) { + throw new Error("prepared OpenCode rename target is not a real directory"); + } + preparedNewDirIdentity = { dev: created.dev, ino: created.ino }; + prepareOpencodeNodeForProfileWrite(newDir); + } cpSync(oldDir, newDir, { recursive: true }); + if (boundRenameProfile && !ownsPreparedNewDir()) { + throw new Error("prepared OpenCode rename target changed during copy"); + } const newLock = join(newDir, "rename.lock"); if (existsSync(newLock)) rmSync(newLock, { force: true }); // lock belongs to oldDir only // #146 — cpSync also copies the old node's `.pid`; that PID belongs to the @@ -5363,12 +5575,18 @@ anet node rename [--force] console.error(`[anet] rename PHASE 1 failed: ${e.message} — rolling back`); let bindingCleanupError: any; if (existsSync(newDir)) { - try { - removeOpencodeRuntimeBinding(newDir, opencodeBindingHome()); - } catch (cleanupError: any) { - bindingCleanupError = cleanupError; + if (!ownsPreparedNewDir()) { + bindingCleanupError = new Error( + "rename target is not the directory created by this transaction; preserved without mutation", + ); + } else { + try { + removeOpencodeRuntimeBinding(newDir, opencodeBindingHome()); + } catch (cleanupError: any) { + bindingCleanupError = cleanupError; + } + if (!bindingCleanupError) rmSync(newDir, { recursive: true, force: true }); } - if (!bindingCleanupError) rmSync(newDir, { recursive: true, force: true }); } // PR-3 (#110) — txnId is null for local-only renames; no server abort needed. if (txnId) { @@ -5406,12 +5624,18 @@ anet node rename [--force] console.error(`[anet] rename PHASE 2 C1 (commhub commit) failed: ${commit.error} — rolling back`); let bindingCleanupError: any; if (existsSync(newDir)) { - try { - removeOpencodeRuntimeBinding(newDir, opencodeBindingHome()); - } catch (cleanupError: any) { - bindingCleanupError = cleanupError; + if (!ownsPreparedNewDir()) { + bindingCleanupError = new Error( + "rename target is not the directory created by this transaction; preserved without mutation", + ); + } else { + try { + removeOpencodeRuntimeBinding(newDir, opencodeBindingHome()); + } catch (cleanupError: any) { + bindingCleanupError = cleanupError; + } + if (!bindingCleanupError) rmSync(newDir, { recursive: true, force: true }); } - if (!bindingCleanupError) rmSync(newDir, { recursive: true, force: true }); } // PR-3 (#110) — txnId is null for local-only path; nothing to abort server-side. if (txnId) { @@ -5650,6 +5874,52 @@ function stopNode(nodeId: string): boolean { } } +async function stopNodeAuthoritatively( + nodeId: string, + profile: Profile, + requireBoundOpencode = false, +): Promise { + const binding = readOpencodeRuntimeBinding(join(nodesDir(), nodeId), opencodeBindingHome()); + if (!binding) { + if (requireBoundOpencode) { + throw new Error("bound OpenCode identity disappeared during stop; refusing legacy PID fallback"); + } + return stopNode(nodeId); + } + + const initial = findBoundOpencodeProcesses(nodeId, profile); + if (initial === null) { + throw new Error("cannot inspect process table for bound OpenCode stop"); + } + const pidFile = join(nodesDir(), nodeId, ".pid"); + if (initial.length === 0 && existsSync(pidFile)) { + const recordedPid = Number(readFileSync(pidFile, "utf8").trim()); + if (Number.isSafeInteger(recordedPid) && recordedPid > 0 && pidAlive(recordedPid)) { + throw new Error( + `live recorded pid ${recordedPid} is not an authentic bound OpenCode wrapper; state retained`, + ); + } + } + for (const identity of initial) { + if (!(await terminateBoundOpencodeProcess(identity, nodeId, profile))) { + throw new Error( + `bound OpenCode process ${identity.pid} did not exit after SIGTERM; ` + + `wrapper retained so its detached ACP process group is not orphaned`, + ); + } + } + const survivors = findBoundOpencodeProcesses(nodeId, profile); + if (survivors === null) { + throw new Error("cannot re-scan process table for bound OpenCode stop"); + } + if (survivors.length > 0) { + throw new Error(`bound OpenCode process survived stop: ${survivors.map(p => p.pid).join(", ")}`); + } + // PID state is cleared only after the exact process-table receipt has gone. + rmSync(pidFile, { force: true }); + return initial.length > 0; +} + async function stopCommand() { const ref = args[1]; if (!ref) { @@ -5667,23 +5937,55 @@ Stop a running agent node. process.exit(1); } - const displayName = nodeDisplayName(resolved.id, resolved.profile); - // #122 — auto-tmux on start needs symmetric cleanup on stop. Kill the - // tmux session first (idempotent — has-session check guards), then SIGTERM - // the recorded PID and notify the hub. Order matters: killing tmux kills - // any child processes too, which makes `stopNode` mostly a defensive op - // when the PID file is stale. - const tmuxKilled = tmuxSessionRunning(displayName); - if (tmuxKilled) killTmuxSession(displayName); - const killed = stopNode(resolved.id); + let trustedProfile: Profile; + try { + trustedProfile = resolveStartProfile(resolved.id, resolved.profile).profile; + } catch (error: any) { + console.error(`[anet] refusing stop before mutation: ${error?.message || error}`); + process.exit(1); + } + const displayName = nodeDisplayName(resolved.id, trustedProfile); + let isBoundOpencode = false; + try { + isBoundOpencode = !!readOpencodeRuntimeBinding( + join(nodesDir(), resolved.id), + opencodeBindingHome(), + ); + } catch (error: any) { + console.error(`[anet] refusing stop before mutation: ${error?.message || error}`); + process.exit(1); + } + + // Generic runtimes retain the existing tmux-first behavior. A bound + // OpenCode node must first let its exact wrapper run the group-aware async + // shutdown; killing tmux first can bypass that handler and orphan ACP. + let tmuxKilled = false; + if (!isBoundOpencode && tmuxSessionRunning(displayName)) { + killTmuxSession(displayName); + tmuxKilled = true; + } + let killed = false; + try { + killed = await stopNodeAuthoritatively(resolved.id, trustedProfile, isBoundOpencode); + } catch (error: any) { + console.error(`[anet] refusing/failed authoritative stop: ${error?.message || error}`); + process.exit(1); + } + // A same-name tmux session can be recreated between process verification + // and cleanup. Never mutate it for bound OpenCode; report the residual for + // explicit operator inspection instead of creating an alias TOCTOU kill. + const residualBoundTmux = isBoundOpencode && tmuxSessionRunning(displayName); // Always notify server — even if PID file missing, server may have stale session - await notifyServerOffline(resolved.profile, resolved.id); + await notifyServerOffline(trustedProfile, resolved.id); if (killed || tmuxKilled) { const what = [tmuxKilled ? "tmux" : null, killed ? "process" : null].filter(Boolean).join(" + "); console.log(`[anet] Stopped "${displayName}" (${what} killed, server notified)`); } else { console.log(`[anet] "${displayName}" is not running locally (server notified offline)`); } + if (residualBoundTmux) { + console.warn(`[anet] bound OpenCode tmux session "${displayName}" was not mutated; inspect it explicitly`); + } } // ── project (#117) — cwd-wide node orchestration ───────────────────── @@ -5695,6 +5997,13 @@ Stop a running agent node. interface ProjectNode { id: string; alias: string; profile: Profile | null; invalid?: string; } +function projectNodeHasOpencodeBinding(node: ProjectNode): boolean { + return !!readOpencodeRuntimeBinding( + join(nodesDir(), node.id), + opencodeBindingHome(), + ); +} + function printProjectUsage() { console.log(` anet project [options] @@ -5911,8 +6220,20 @@ async function projectUp(invokedAs = "anet project up") { if (n.invalid) { console.log(` ⚠ ${n.alias} — invalid config: ${n.invalid}`); invalid.push({ alias: n.alias, reason: n.invalid }); - } else { + continue; + } + try { + if (projectNodeHasOpencodeBinding(n)) { + const reason = "bound opencode-cli nodes require explicit 'anet node start/stop' lifecycle"; + console.log(` ⚠ ${n.alias} — invalid config: ${reason}`); + invalid.push({ alias: n.alias, reason }); + continue; + } startable.push(n); + } catch (error: any) { + const reason = `runtime binding preflight failed: ${error?.message ?? error}`; + console.log(` ⚠ ${n.alias} — invalid config: ${reason}`); + invalid.push({ alias: n.alias, reason }); } } @@ -5965,8 +6286,20 @@ async function projectRestart() { if (n.invalid) { console.log(` ⚠ ${n.alias} — invalid config: ${n.invalid}`); invalid.push({ alias: n.alias, reason: n.invalid }); - } else { + continue; + } + try { + if (projectNodeHasOpencodeBinding(n)) { + const reason = "bound opencode-cli restart requires explicit stop, review, then start"; + console.log(` ⚠ ${n.alias} — invalid config: ${reason}`); + invalid.push({ alias: n.alias, reason }); + continue; + } startable.push(n); + } catch (error: any) { + const reason = `runtime binding preflight failed: ${error?.message ?? error}`; + console.log(` ⚠ ${n.alias} — invalid config: ${reason}`); + invalid.push({ alias: n.alias, reason }); } } @@ -6007,16 +6340,39 @@ async function projectDown() { return; } console.log(`\n[anet] anet project down — ${nodes.length} node(s) in ${process.cwd()}`); - let stopped = 0, alreadyDown = 0; + let stopped = 0, alreadyDown = 0, failed = 0; for (const n of nodes) { - const tmuxAlive = tmuxSessionRunning(n.alias); - if (tmuxAlive) killTmuxSession(n.alias); - const localKilled = stopNode(n.id); - if (n.profile) { + let bound = false; + let trustedProfile = n.profile; + try { + if (!trustedProfile) throw new Error("profile is unavailable"); + trustedProfile = resolveStartProfile(n.id, trustedProfile).profile; + bound = projectNodeHasOpencodeBinding(n); + } catch (error: any) { + console.log(` ✗ ${n.alias} — preflight failed: ${error?.message ?? error}`); + failed++; + continue; + } + let tmuxAlive = false; + let localKilled = false; + try { + if (bound) { + localKilled = await stopNodeAuthoritatively(n.id, trustedProfile, true); + } else { + tmuxAlive = tmuxSessionRunning(n.alias); + if (tmuxAlive) killTmuxSession(n.alias); + localKilled = stopNode(n.id); + } + } catch (error: any) { + console.log(` ✗ ${n.alias} — authoritative stop failed: ${error?.message ?? error}`); + failed++; + continue; + } + if (trustedProfile) { // Hub may be down (the very scenario this command runs in) — cap notify // at 2s so a 22-node teardown isn't held hostage by 44 hung fetches. await Promise.race([ - notifyServerOffline(n.profile, n.id), + notifyServerOffline(trustedProfile, n.id), new Promise(r => setTimeout(r, 2000)), ]).catch(() => {}); } @@ -6027,8 +6383,16 @@ async function projectDown() { console.log(` · ${n.alias} — not running`); alreadyDown++; } + if (bound && tmuxSessionRunning(n.alias)) { + console.warn(` ⚠ ${n.alias} — same-name tmux retained for explicit inspection`); + } } - console.log(`\n ${stopped}/${nodes.length} stopped${alreadyDown ? ` · ${alreadyDown} were not running` : ""}\n`); + if (failed > 0) process.exitCode = 1; + console.log( + `\n ${stopped}/${nodes.length} stopped` + + `${alreadyDown ? ` · ${alreadyDown} were not running` : ""}` + + `${failed ? ` · ${failed} failed safely` : ""}\n`, + ); } // ── loop ── (#144 round-6) @@ -6215,10 +6579,6 @@ Delete a node and its config. Use --force to skip confirmation. const displayName = nodeDisplayName(nodeId, profile); const opts = parseOpts(); - // Stop if running + notify server - stopNode(nodeId); - await notifyServerOffline(profile, nodeId); - const nodeDir = join(nodesDir(), nodeId); if (!existsSync(nodeDir)) { console.error(`Node directory not found: ${nodeDir}`); @@ -6232,6 +6592,30 @@ Delete a node and its config. Use --force to skip confirmation. return; } + // Deletion has no durable lifecycle lock yet. For an externally-bound + // OpenCode node, even a successful stop followed by a concurrent start can + // race binding/config removal. Refuse the whole operation before signaling + // or mutating anything; explicit stop remains available. + let boundOpencode = false; + try { + boundOpencode = !!readOpencodeRuntimeBinding(nodeDir, opencodeBindingHome()); + } catch (error: any) { + console.error(`[anet] refusing delete before mutation: ${error?.message || error}`); + process.exit(1); + } + if (boundOpencode) { + console.error( + "[anet] refusing delete of bound opencode-cli node without an external lifecycle lock; " + + "run 'anet node stop' and retain the node for explicit inspection", + ); + process.exit(1); + } + + // Generic runtimes retain the historical stop + offline behavior, now only + // after the confirmation gate so a dry delete never signals a process. + stopNode(nodeId); + await notifyServerOffline(profile, nodeId); + // The runtime identity lives outside the project tree so a config downgrade // cannot bypass it. Remove that exact record first for every runtime: this // also repairs an older/downgraded profile whose config no longer says @@ -10084,6 +10468,26 @@ async function createBatch(opts: BatchOptions): Promise { // batchAliasFor() can never silently escape `.anet/nodes/`. validateNodeName(alias); const nodeDir = batchNodeDirFor(opts, i); + const sessName = `${tmuxPrefix}-${alias}`; + if (tmuxSessionRunning(sessName)) { + console.error(` ❌ ${alias.padEnd(14)} existing tmux owner retained: ${sessName}`); + failed.push(alias); + continue; + } + const existingNodeDir = join(nodeDir, ".anet", "nodes", alias); + if (existsSync(existingNodeDir)) { + try { + if (readOpencodeRuntimeBinding(existingNodeDir, opencodeBindingHome())) { + console.error(` ❌ ${alias.padEnd(14)} bound opencode-cli node retained; stop explicitly`); + failed.push(alias); + continue; + } + } catch (error: any) { + console.error(` ❌ ${alias.padEnd(14)} binding preflight failed: ${error?.message ?? error}`); + failed.push(alias); + continue; + } + } mkdirSync(nodeDir, { recursive: true }); process.chdir(nodeDir); @@ -10162,7 +10566,10 @@ async function createBatch(opts: BatchOptions): Promise { } const nodeDir = nodeI > 0 ? batchNodeDirFor(opts, nodeI) : opts.workdir; const sessName = `${tmuxPrefix}-${alias}`; - killTmuxSession(sessName); + if (tmuxSessionRunning(sessName)) { + console.error(` ❌ tmux ${alias}: owner appeared during create; retained`); + continue; + } try { process.chdir(nodeDir); startNodeTmuxSession(sessName, alias); @@ -10186,6 +10593,33 @@ async function createBatch(opts: BatchOptions): Promise { // - cleanup stop + rm -rf /node* + remove empty // - list enumerate distinct `` groups currently active in tmux +function findBatchBoundOpencodeNodes(workdir: string): string[] { + const roots = [join(workdir, ".anet", "nodes")]; + if (existsSync(workdir)) { + for (const entry of readdirSync(workdir)) { + if (!entry.startsWith("node")) continue; + const candidate = join(workdir, entry); + const stat = lstatSync(candidate); + if (stat.isDirectory() && !stat.isSymbolicLink()) { + roots.push(join(candidate, ".anet", "nodes")); + } + } + } + const bound: string[] = []; + for (const root of roots) { + if (!existsSync(root)) continue; + for (const nodeId of readdirSync(root)) { + const nodeDir = join(root, nodeId); + const stat = lstatSync(nodeDir); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`batch lifecycle refuses non-directory node state: ${nodeDir}`); + } + if (readOpencodeRuntimeBinding(nodeDir, opencodeBindingHome())) bound.push(nodeDir); + } + } + return bound; +} + function batchLifecycle(opts: { prefix: string; verb: "start" | "stop" | "restart" | "cleanup" | "list"; workdir?: string }) { const { prefix, verb, workdir } = opts; @@ -10216,6 +10650,38 @@ function batchLifecycle(opts: { prefix: string; verb: "start" | "stop" | "restar return; } + if (verb === "start") { + // Phase 1 never implemented in-place start. Return before the shared stop + // pass: the old ordering killed every matching session for a hint-only + // command. + console.log("[anet] 'start' in-place not yet implemented (Phase 1 scaffold). Re-run:"); + console.log(" anet create --batch # generic"); + console.log(" anet demo sci-team # sci-team preset"); + return; + } + + if (!workdir) { + console.error( + "[anet] refusing batch lifecycle without --workdir: ownership cannot be proven for bound OpenCode nodes", + ); + process.exitCode = 1; + return; + } + try { + const bound = findBatchBoundOpencodeNodes(resolve(workdir)); + if (bound.length > 0) { + console.error( + `[anet] refusing batch ${verb}: ${bound.length} bound opencode-cli node(s) require explicit node stop`, + ); + process.exitCode = 1; + return; + } + } catch (error: any) { + console.error(`[anet] refusing batch ${verb} before mutation: ${error?.message ?? error}`); + process.exitCode = 1; + return; + } + // stop/restart/cleanup share a "kill matching tmux sessions" pass. let killedCount = 0; try { @@ -10264,7 +10730,7 @@ function batchLifecycle(opts: { prefix: string; verb: "start" | "stop" | "restar return; } - if (verb === "restart" || verb === "start") { + if (verb === "restart") { // Phase 1: restart/start in-place is not yet wired (would need to walk // saved .anet/nodes//config.json under /node*/ and // re-launch tmux). For now, hint the user to re-run the create wizard. @@ -11342,7 +11808,14 @@ switch (command) { // explicitly in both branches so readline / @inquirer signal handlers // don't keep the event loop alive past the dispatch. main().then( - () => { if (process.env.ANET_INTERNAL_KEEP_PROCESS !== "1") process.exit(0); }, + () => { + if (process.env.ANET_INTERNAL_KEEP_PROCESS !== "1") { + // Several safe-refusal paths intentionally set exitCode after printing a + // precise diagnostic. Preserve that status instead of turning a verified + // refusal into shell success at the shared CLI terminator. + process.exit(process.exitCode ?? 0); + } + }, (err: any) => { // #237 — Friendly classification for unhandled fetch errors. Replaces // the bare "FATAL: TypeError: fetch failed + 10-line Node stack" output diff --git a/agent-network/package-lock.json b/agent-network/package-lock.json index a7345629..f5a673eb 100644 --- a/agent-network/package-lock.json +++ b/agent-network/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sleep2agi/agent-network", - "version": "2.3.0-preview.34", + "version": "2.3.0-preview.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sleep2agi/agent-network", - "version": "2.3.0-preview.34", + "version": "2.3.0-preview.35", "license": "Apache-2.0", "dependencies": { "@inquirer/prompts": "^8.4.3", diff --git a/agent-network/package.json b/agent-network/package.json index eace3f91..4cc07e1d 100644 --- a/agent-network/package.json +++ b/agent-network/package.json @@ -1,6 +1,6 @@ { "name": "@sleep2agi/agent-network", - "version": "2.3.0-preview.34", + "version": "2.3.0-preview.35", "description": "AI Agent Network CLI — Local-first multi-agent orchestration across 6 runtimes (Claude Code CLI / Claude Agent SDK / Codex SDK / Codex app-server / Grok Build ACP / OpenCode CLI) and 8+ LLM providers. Apache 2.0.", "type": "module", "main": "dist/src/client.js", diff --git a/agent-network/src/opencode-agent-node-pair.ts b/agent-network/src/opencode-agent-node-pair.ts index 4f3f26ac..ef97fa1c 100644 --- a/agent-network/src/opencode-agent-node-pair.ts +++ b/agent-network/src/opencode-agent-node-pair.ts @@ -17,8 +17,8 @@ import type { Stats } from "fs"; import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from "path"; import { opencodeOwnedPathModeIsSafe } from "./opencode-owner-mode"; -export const OPENCODE_AGENT_NETWORK_VERSION = "2.3.0-preview.34"; -export const OPENCODE_AGENT_NODE_VERSION = "2.5.0-preview.26"; +export const OPENCODE_AGENT_NETWORK_VERSION = "2.3.0-preview.35"; +export const OPENCODE_AGENT_NODE_VERSION = "2.5.0-preview.27"; export const OPENCODE_AGENT_NODE_SPEC = `@sleep2agi/agent-node@${OPENCODE_AGENT_NODE_VERSION}`; diff --git a/agent-network/src/opencode-wrapper-stop.test.ts b/agent-network/src/opencode-wrapper-stop.test.ts new file mode 100644 index 00000000..e07eb03f --- /dev/null +++ b/agent-network/src/opencode-wrapper-stop.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { spawn, type ChildProcess } from "child_process"; +import { readFileSync } from "fs"; +import { + signalExactProcessGracefully, + stopExactProcessTermOnly, + type ExactProcessState, +} from "./opencode-wrapper-stop"; + +const children: ChildProcess[] = []; +const detachedGroups: number[] = []; + +function procStartTime(pid: number): string | undefined { + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const close = stat.lastIndexOf(")"); + return close < 0 ? undefined : stat.slice(close + 2).trim().split(/\s+/)[19]; + } catch { + return undefined; + } +} + +function exactState(pid: number, startTime: string): ExactProcessState { + const current = procStartTime(pid); + if (current === undefined) { + try { + process.kill(pid, 0); + return "unknown"; + } catch { + return "exited-or-reused"; + } + } + return current === startTime ? "same" : "exited-or-reused"; +} + +async function waitForExit(child: ChildProcess, timeoutMs = 2_000): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await Promise.race([ + new Promise((resolve) => child.once("exit", () => resolve())), + new Promise((_, reject) => setTimeout(() => reject(new Error("child did not exit")), timeoutMs)), + ]); +} + +async function waitForProcState(pid: number, wanted: string, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const state = readFileSync(`/proc/${pid}/stat`, "utf8").match(/\)\s+([A-Z])/i)?.[1]; + if (state === wanted) return; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`process ${pid} did not enter state ${wanted}`); +} + +afterEach(async () => { + for (const pgid of detachedGroups.splice(0)) { + try { process.kill(-pgid, "SIGKILL"); } catch {} + } + for (const child of children.splice(0)) { + if (child.pid && child.exitCode === null && child.signalCode === null) { + try { process.kill(child.pid, "SIGCONT"); } catch {} + try { process.kill(child.pid, "SIGKILL"); } catch {} + await waitForExit(child).catch(() => {}); + } + } +}); + +describe("bound OpenCode wrapper TERM-only stop", () => { + test("a responsive wrapper exits after SIGTERM", async () => { + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdio: "ignore", + }); + children.push(child); + const pid = child.pid!; + const startTime = procStartTime(pid)!; + + expect(await stopExactProcessTermOnly({ + pid, + readState: () => exactState(pid, startTime), + timeoutMs: 2_000, + pollMs: 10, + })).toBe(true); + await waitForExit(child); + }); + + test("a parent-forwarded SIGINT retains a SIGSTOP'd wrapper and detached child", async () => { + const wrapperSource = [ + 'const { spawn } = require("child_process");', + 'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"],', + ' { detached: true, stdio: "ignore" });', + 'process.stdout.write(String(child.pid) + "\\n");', + 'setInterval(() => {}, 1000);', + ].join("\n"); + const child = spawn(process.execPath, ["-e", wrapperSource], { + stdio: ["ignore", "pipe", "inherit"], + }); + children.push(child); + const pid = child.pid!; + const startTime = procStartTime(pid)!; + const detachedPid = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("wrapper did not report detached child")), 2_000); + child.stdout!.once("data", (chunk) => { + clearTimeout(timer); + resolve(Number(String(chunk).trim())); + }); + }); + detachedGroups.push(detachedPid); + expect(procStartTime(detachedPid)).toBeDefined(); + process.kill(pid, "SIGSTOP"); + await waitForProcState(pid, "T"); + + expect(await signalExactProcessGracefully({ + pid, + readState: () => exactState(pid, startTime), + timeoutMs: 100, + pollMs: 10, + }, "SIGINT")).toBe(false); + expect(exactState(pid, startTime)).toBe("same"); + expect(readFileSync(`/proc/${pid}/stat`, "utf8").match(/\)\s+([A-Z])/i)?.[1]).toBe("T"); + // The failure retains the ownership relationship: the wrapper is still + // present to resume its shutdown handler, and its detached ACP analogue + // remains live instead of becoming an ownerless survivor after SIGKILL. + expect(procStartTime(detachedPid)).toBeDefined(); + expect(Number(readFileSync(`/proc/${detachedPid}/stat`, "utf8") + .slice(readFileSync(`/proc/${detachedPid}/stat`, "utf8").lastIndexOf(")") + 2) + .trim().split(/\s+/)[2])).toBe(detachedPid); + }); +}); diff --git a/agent-network/src/opencode-wrapper-stop.ts b/agent-network/src/opencode-wrapper-stop.ts new file mode 100644 index 00000000..3ecc28be --- /dev/null +++ b/agent-network/src/opencode-wrapper-stop.ts @@ -0,0 +1,54 @@ +export type ExactProcessState = "same" | "exited-or-reused" | "unknown"; + +export interface TermOnlyExactProcessStopOptions { + pid: number; + /** Revalidate the caller's exact PID/start-time/argv receipt. */ + readState: () => ExactProcessState; + timeoutMs?: number; + pollMs?: number; +} + +export type GracefulExactProcessSignal = "SIGTERM" | "SIGINT"; + +/** + * Signal an exact process with a graceful signal only. + * + * Bound OpenCode's agent-node wrapper owns a detached ACP process group. If + * the wrapper cannot run its async SIGTERM/SIGINT handler (for example it is + * SIGSTOP'd or its event loop is wedged), SIGKILLing only the wrapper would + * orphan that group. Therefore a timeout is a hard failure: leave the + * wrapper and its descendants intact for inspection/retry, and never claim a + * successful stop. + */ +export async function signalExactProcessGracefully( + options: TermOnlyExactProcessStopOptions, + signal: GracefulExactProcessSignal, +): Promise { + const timeoutMs = options.timeoutMs ?? 8_000; + const pollMs = options.pollMs ?? 250; + const initial = options.readState(); + if (initial === "exited-or-reused") return true; + if (initial === "unknown") return false; + + try { + process.kill(options.pid, signal); + } catch { + const afterError = options.readState(); + return afterError === "exited-or-reused"; + } + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = options.readState(); + if (state === "exited-or-reused") return true; + if (state === "unknown") return false; + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + return options.readState() === "exited-or-reused"; +} + +export function stopExactProcessTermOnly( + options: TermOnlyExactProcessStopOptions, +): Promise { + return signalExactProcessGracefully(options, "SIGTERM"); +} diff --git a/agent-node/package.json b/agent-node/package.json index 1c0498b1..07ed6792 100644 --- a/agent-node/package.json +++ b/agent-node/package.json @@ -1,6 +1,6 @@ { "name": "@sleep2agi/agent-node", - "version": "2.5.0-preview.26", + "version": "2.5.0-preview.27", "description": "AI Agent runtime for CommHub networks. Supports Claude Code CLI, Claude Agent SDK, Codex SDK, Codex app-server, Grok Build ACP, and OpenCode CLI.", "bin": { "agent-node": "dist/cli.js" diff --git a/agent-node/src/cli.ts b/agent-node/src/cli.ts index 837a22ed..5d9a7d0b 100644 --- a/agent-node/src/cli.ts +++ b/agent-node/src/cli.ts @@ -2690,12 +2690,36 @@ function sanitizeGrokCommhubLeak(text: string): string { const OPENCODE_PROCESS_BUNDLE_MARKER = "processWithOpencode"; async function processWithOpencode(task: string, _from: string, _images?: string[]): Promise { debug(`[${OPENCODE_PROCESS_BUNDLE_MARKER}] dispatch`); + if (shuttingDown) { + throw new Error("agent-node is shutting down; refusing to open a new OpenCode process tree"); + } const { openOpencodeRuntime, opencodeThink } = await import("./runtime/opencode-acp/runtime"); - // Reset the holder if the child has already exited — the next call - // will re-open with session/load per the persisted sessionId. + // Dynamic import is an await boundary. A signal can stop the old client and + // clear its holder while this function is suspended; without this second + // gate the resumed turn creates a fresh credential root after shutdown has + // already passed closeOpencodeRuntime(), then process.exit strands it. + if (shuttingDown) { + throw new Error("agent-node is shutting down; refusing to open a new OpenCode process tree"); + } + + if (!opencodeRuntimeSession && opencodeRuntimeClient) { + if (!opencodeRuntimeClient.cleanupConfirmed) { + throw opencodeRuntimeClient.cleanupError + ?? new Error("previous OpenCode open attempt still owns a process tree"); + } + opencodeRuntimeClient = null; + } + + // Re-open only after the supervisor has proved its whole session empty. + // A dead anchor with residual members is a fail-closed owner state, not a + // license to create a second OpenCode tree. if (opencodeRuntimeSession && !opencodeRuntimeSession.client.isRunning) { + if (!opencodeRuntimeSession.client.cleanupConfirmed) { + throw opencodeRuntimeSession.client.cleanupError + ?? new Error("previous OpenCode supervisor cleanup is unconfirmed; refusing a second child"); + } log(`[opencode] previous child exited — reopening on this turn`); opencodeRuntimeSession = null; opencodeRuntimeClient = null; @@ -2758,19 +2782,14 @@ async function processWithOpencode(task: string, _from: string, _images?: string async function closeOpencodeRuntime(reason: string): Promise { const client = opencodeRuntimeClient ?? opencodeRuntimeSession?.client ?? null; - if (client?.isRunning) { - log(`[opencode] stopping ACP child (${reason})`); - await Promise.race([ - client.stop("SIGTERM"), - new Promise((resolve) => setTimeout(resolve, 1_000)), - ]).catch((e: any) => { - warn(`[opencode] graceful stop failed: ${e?.message || e}`); - }); - if (client.isRunning) { - await client.stop("SIGKILL").catch((e: any) => { - warn(`[opencode] forced stop failed: ${e?.message || e}`); - }); - } + if (!client) return; + if (!client.cleanupConfirmed) { + log(`[opencode] stopping ACP supervisor (${reason})`); + await client.stop("SIGTERM", 4_000); + } + if (!client.cleanupConfirmed) { + throw client.cleanupError + ?? new Error("OpenCode supervisor stop returned without confirmed session cleanup"); } opencodeRuntimeClient = null; opencodeRuntimeSession = null; @@ -3201,6 +3220,12 @@ function think(task: string, from: string, taskId: string | null, images?: strin return Promise.resolve(`执行出错: agent-node 重启中(config-apply drain),任务暂不处理,请稍后重发`); } const run = async () => { + // Calls already queued before the signal did not see the admission check + // below. Re-check at execution time so no runtime can begin after shutdown + // has started; OpenCode additionally re-checks across its dynamic import. + if (shuttingDown) { + return "执行出错: agent-node 正在关闭,任务暂不处理,请稍后重发"; + } // Expose CURRENT_TASK_ID for runtime processes (Claude SDK / Codex) // so the LLM can pass it as parent_task_id when delegating sub-tasks. // Server has a fallback (latest open task to this caller) but explicit @@ -4650,7 +4675,19 @@ const shutdown = async () => { if (shuttingDown) return; shuttingDown = true; log("shutting down..."); - await closeOpencodeRuntime("signal shutdown"); + try { + await closeOpencodeRuntime("signal shutdown"); + } catch (shutdownError: any) { + // Keep agent-node alive as the supervisor owner. A later TERM/INT/HUP can + // retry after SIGCONT or after a transient residual process exits. + shuttingDown = false; + process.exitCode = 1; + warn( + `[opencode] shutdown refused while owner tree is retained: ` + + `${shutdownError?.message ?? shutdownError}`, + ); + return; + } // #261 P0-1 — gate the feishu supervisor loop so it stops re-forking // on the soon-to-arrive child exit. SIGTERM each tracked worker (give // it 500 ms to exit gracefully), then SIGKILL holdouts. Without this, @@ -4674,6 +4711,7 @@ const shutdown = async () => { }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); +process.on("SIGHUP", shutdown); for (const channel of TELEGRAM_CHANNELS) connectTelegram(channel); for (const channel of FEISHU_CHANNELS) void connectFeishu(channel); connectSSE(); diff --git a/agent-node/src/runtime/opencode-acp/child-env.test.ts b/agent-node/src/runtime/opencode-acp/child-env.test.ts index 42db93b5..c9bcbe87 100644 --- a/agent-node/src/runtime/opencode-acp/child-env.test.ts +++ b/agent-node/src/runtime/opencode-acp/child-env.test.ts @@ -20,10 +20,12 @@ import { dirname, isAbsolute, join, relative, resolve } from "path"; import { tmpdir } from "os"; import { assertNoManagedOpencodeConfig, + bindOpencodeChildProcessGroup, buildOpencodeChildEnv, cleanupOpencodeChildEnv, OPENCODE_ANCESTOR_DISCOVERY_CANDIDATES, OPENCODE_LOCAL_TOOL_KEYS, + OPENCODE_LAUNCH_CHILD_FILE, OPENCODE_LAUNCH_OWNER_FILE, OPENCODE_UNATTENDED_DENY_TOOL_KEYS, opencodeManagedConfigCandidates, @@ -31,6 +33,14 @@ import { revalidateOpencodeChildLaunch, } from "./child-env"; +async function waitUntil(predicate: () => boolean, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error("timed out waiting for process-group fixture"); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 20)); + } +} + function makeLaunchBase(label: string): string { if (process.platform !== "linux" || process.getuid === undefined) { throw new Error("OpenCode launch isolation tests require Linux uid semantics"); @@ -813,6 +823,162 @@ describe("buildOpencodeChildEnv — deny-by-default boundary", () => { } }); + test("durable process-group binding protects opaque descendants across later and crash-style sweeps", async () => { + const workDir = mkdtempSync(join(tmpdir(), "opencode-opaque-group-")); + const launchBase = makeLaunchBase("opaque-group"); + const readyFile = join(tmpdir(), `opencode-opaque-ready-${process.pid}-${Date.now()}`); + let groupId: number | undefined; + let survivorPid: number | undefined; + let leader: ChildProcess | null = null; + let first: NodeJS.ProcessEnv | undefined; + let later: NodeJS.ProcessEnv | undefined; + try { + first = buildOpencodeChildEnv({ + workDir, + cwd: join(workDir, "requested-project"), + launchBase, + parentEnv: {}, + unsafeTools: true, + }); + const firstRoot = dirname(first.XDG_DATA_HOME!); + const leaderProgram = [ + 'const { spawn } = require("child_process");', + 'const { existsSync } = require("fs");', + 'const child = spawn("python3", ["-c", process.env.PYTHON_CODE],', + ' { env: process.env, stdio: "ignore" });', + 'child.unref();', + 'const timer = setInterval(() => {', + ' if (existsSync(process.env.READY_FILE)) { clearInterval(timer); process.exit(0); }', + '}, 10);', + ].join("\n"); + const pythonProgram = [ + "import ctypes, os, time", + "libc = ctypes.CDLL(None)", + "assert libc.prctl(4, 0, 0, 0, 0) == 0", + 'with open(os.environ["READY_FILE"], "w") as ready:', + " ready.write(str(os.getpid()))", + " ready.flush()", + "time.sleep(60)", + ].join("\n"); + leader = spawn(process.execPath, ["-e", leaderProgram], { + detached: true, + env: { + ...first, + PATH: process.env.PATH, + READY_FILE: readyFile, + PYTHON_CODE: pythonProgram, + }, + stdio: "ignore", + }); + const leaderExited = once(leader, "exit"); + await once(leader, "spawn"); + const leaderPid = leader.pid!; + groupId = leaderPid; + const leaderIdentity = readOpencodeProcessIdentity(leaderPid); + expect(leaderIdentity).toBeDefined(); + bindOpencodeChildProcessGroup(workDir, first, { + pid: leaderPid, + identity: leaderIdentity!, + processGroupId: groupId, + sessionId: groupId, + }); + const childMarkerTemplate = JSON.parse(readFileSync( + join(firstRoot, OPENCODE_LAUNCH_CHILD_FILE), + "utf8", + )); + await waitUntil(() => existsSync(readyFile)); + survivorPid = Number(readFileSync(readyFile, "utf8")); + expect(Number.isSafeInteger(survivorPid) && survivorPid! > 0).toBe(true); + await leaderExited; + leader = null; + + const exitToken = { + pid: leaderPid, + identity: leaderIdentity!, + processGroupId: groupId, + sessionId: groupId, + nativeExitObserved: true as const, + }; + expect(cleanupOpencodeChildEnv(workDir, first, exitToken)).toBe(false); + expect(existsSync(firstRoot)).toBe(true); + + // Clone only the durable marker shapes into an otherwise untracked root. + // This models a fresh agent-node after an owner crash, where only the + // persisted pgid can retain an opaque descendant's credential tree. + const ownerMarkerTemplate = JSON.parse(readFileSync( + join(firstRoot, OPENCODE_LAUNCH_OWNER_FILE), + "utf8", + )); + const crashRoot = join(launchBase, ".anet-opencode-launch-durable-crash"); + mkdirSync(join(crashRoot, "data"), { recursive: true, mode: 0o700 }); + const crashStat = statSync(crashRoot); + writeFileSync(join(crashRoot, OPENCODE_LAUNCH_OWNER_FILE), JSON.stringify({ + ...ownerMarkerTemplate, + ownerPid: 2_147_483_647, + ownerProcessIdentity: "dead-process-identity", + ownerInstanceId: "dead-process-instance-id", + createdAtMs: 0, + launchDev: String(crashStat.dev), + launchIno: String(crashStat.ino), + }), { mode: 0o600 }); + writeFileSync(join(crashRoot, OPENCODE_LAUNCH_CHILD_FILE), JSON.stringify({ + ...childMarkerTemplate, + launchDev: String(crashStat.dev), + launchIno: String(crashStat.ino), + }), { mode: 0o600 }); + + later = buildOpencodeChildEnv({ + workDir, + cwd: join(workDir, "requested-project"), + launchBase, + parentEnv: {}, + }); + expect(existsSync(firstRoot)).toBe(true); + expect(existsSync(crashRoot)).toBe(true); + expect(cleanupOpencodeChildEnv(workDir, later)).toBe(true); + later = undefined; + + process.kill(-groupId, "SIGKILL"); + await waitUntil(() => { + try { + const stat = readFileSync(`/proc/${survivorPid}/stat`, "utf8"); + const close = stat.lastIndexOf(")"); + const state = close < 0 ? "" : stat.slice(close + 1).trim().split(/\s+/)[0]; + return state === "Z" || state === "X"; + } catch { + return true; + } + }); + groupId = undefined; + + later = buildOpencodeChildEnv({ + workDir, + cwd: join(workDir, "requested-project"), + launchBase, + parentEnv: {}, + }); + expect(existsSync(firstRoot)).toBe(false); + expect(existsSync(crashRoot)).toBe(false); + expect(cleanupOpencodeChildEnv(workDir, later)).toBe(true); + later = undefined; + first = undefined; + } finally { + if (groupId !== undefined) { + try { process.kill(-groupId, "SIGKILL"); } catch {} + } + if (leader && leader.exitCode === null && leader.signalCode === null) { + const exited = once(leader, "exit"); + leader.kill("SIGKILL"); + await exited.catch(() => {}); + } + if (later) cleanupOpencodeChildEnv(workDir, later); + if (first) cleanupOpencodeChildEnv(workDir, first); + rmSync(readyFile, { force: true }); + rmSync(launchBase, { recursive: true, force: true }); + rmSync(workDir, { recursive: true, force: true }); + } + }); + test("an exact exited-process identity exemption never hides a live descendant or PID mismatch", async () => { const workDir = mkdtempSync(join(tmpdir(), "opencode-exited-identity-")); const launchBase = makeLaunchBase("exited-identity"); diff --git a/agent-node/src/runtime/opencode-acp/child-env.ts b/agent-node/src/runtime/opencode-acp/child-env.ts index a03b9a5a..2041ab40 100644 --- a/agent-node/src/runtime/opencode-acp/child-env.ts +++ b/agent-node/src/runtime/opencode-acp/child-env.ts @@ -23,6 +23,8 @@ import { basename, dirname, isAbsolute, join, relative, resolve, win32 } from "p const OPENCODE_LAUNCH_PREFIX = ".anet-opencode-launch-"; export const OPENCODE_LAUNCH_OWNER_FILE = ".anet-opencode-launch-owner.json"; const OPENCODE_LAUNCH_OWNER_FORMAT = "anet-opencode-launch-v1"; +export const OPENCODE_LAUNCH_CHILD_FILE = ".anet-opencode-launch-child.json"; +const OPENCODE_LAUNCH_CHILD_FORMAT = "anet-opencode-launch-child-v2"; const STALE_LAUNCH_GRACE_MS = 5 * 60_000; const PROCESS_INSTANCE_ID = randomBytes(24).toString("hex"); @@ -57,6 +59,16 @@ interface LaunchOwnerMarker { launchIno: string; } +interface LaunchChildMarker { + format: typeof OPENCODE_LAUNCH_CHILD_FORMAT; + childPid: number; + childProcessIdentity: string | null; + processGroupId: number; + sessionId: number; + launchDev: string; + launchIno: string; +} + interface TrackedLaunchRoot { dev: number | bigint; ino: number | bigint; @@ -71,6 +83,8 @@ interface TrackedLaunchRoot { enforceManagedPreflight: boolean; managedConfigDir?: string; ancestorSnapshot: AncestorIdentity[]; + processGroupId?: number; + sessionId?: number; active: boolean; } @@ -637,6 +651,17 @@ function atomicWritePrivateFile(path: string, body: string, label: string): void } renameSync(temp, path); assertPrivateRegularFile(path, label); + // Owner/child markers are crash-recovery authority. Persist the directory + // entry after the atomic rename; file fsync alone does not make the rename + // durable. Windows has no portable directory-fsync equivalent here. + if (process.platform !== "win32") { + const parentFd = openSync( + parent, + constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0), + ); + try { fsyncSync(parentFd); } + finally { closeSync(parentFd); } + } } catch (error) { if (fd !== undefined) { try { closeSync(fd); } catch {} @@ -646,26 +671,29 @@ function atomicWritePrivateFile(path: string, body: string, label: string): void } } -export interface OpencodeExitedProcessIdentity { +export interface OpencodeSpawnedProcessIdentity { pid: number; /** Linux /proc start-ticks identity; unavailable on Windows/macOS. */ identity: string | null; + /** Dedicated POSIX process group created for this ACP child. */ + processGroupId?: number | null; + /** Dedicated Linux session held by the ACP supervisor anchor. */ + sessionId?: number | null; +} + +export interface OpencodeExitedProcessIdentity extends OpencodeSpawnedProcessIdentity { nativeExitObserved: true; } interface LinuxProcessStat { identity: string; state: string; + processGroupId: number; + sessionId: number; } function readLinuxProcessStat(pid: number): LinuxProcessStat | undefined { - if (process.platform !== "linux") { - // A native ChildProcess exit is the strongest portable proof available. - // Safe mode cannot spawn tools/MCP/web children; unsafe mode is an - // explicit trusted-task opt-in. Delete promptly instead of retaining - // copied credentials until an unrelated future launch. - return exitedProcess?.nativeExitObserved === true ? false : undefined; - } + if (process.platform !== "linux") return undefined; try { // /proc//stat field 22 is the process start time in clock ticks. It // disambiguates PID reuse without trusting wall-clock timestamps. `comm` @@ -675,8 +703,14 @@ function readLinuxProcessStat(pid: number): LinuxProcessStat | undefined { if (close < 0) return undefined; const fieldsFromState = stat.slice(close + 1).trim().split(/\s+/); const state = fieldsFromState[0]; + const processGroupId = Number(fieldsFromState[2]); + const sessionId = Number(fieldsFromState[3]); const startTicks = fieldsFromState[19]; - return state && startTicks ? { identity: `${pid}:${startTicks}`, state } : undefined; + return state && startTicks + && Number.isSafeInteger(processGroupId) && processGroupId > 0 + && Number.isSafeInteger(sessionId) && sessionId > 0 + ? { identity: `${pid}:${startTicks}`, state, processGroupId, sessionId } + : undefined; } catch { return undefined; } @@ -698,6 +732,18 @@ function isPidAlive(pid: number): boolean { } } +function isProcessGroupAlive(processGroupId: number): boolean { + if (process.platform === "win32" + || !Number.isSafeInteger(processGroupId) || processGroupId <= 0) return false; + try { + process.kill(-processGroupId, 0); + return true; + } catch (error: any) { + // EPERM proves the group exists; ESRCH is the only complete negative. + return error?.code === "EPERM"; + } +} + function markerOwnerIsLive(marker: LaunchOwnerMarker): boolean { if (marker.ownerPid === process.pid && marker.ownerInstanceId === PROCESS_INSTANCE_ID) { return true; @@ -725,8 +771,19 @@ function launchRootReferencedByLiveProcess( launchRoot: string, safeWorkspace: string | undefined, exitedProcess?: OpencodeExitedProcessIdentity, + persistedProcessGroupId?: number, + persistedSessionId?: number, ): boolean | undefined { - if (process.platform !== "linux") return undefined; + const processGroupId = exitedProcess?.processGroupId ?? persistedProcessGroupId; + const sessionId = exitedProcess?.sessionId ?? persistedSessionId; + if (process.platform !== "linux") { + // A surviving dedicated POSIX group is stronger evidence than the direct + // child's exit and covers descendants whose environment is opaque. + if (processGroupId && isProcessGroupAlive(processGroupId)) return true; + // On platforms without procfs, a native ChildProcess exit is the strongest + // portable proof available once the dedicated group is gone. + return exitedProcess?.nativeExitObserved === true ? false : undefined; + } let entries: string[]; try { entries = readdirSync("/proc"); @@ -760,6 +817,22 @@ function launchRootReferencedByLiveProcess( && (procStat.state === "Z" || procStat.state === "X")) { continue; } + // Descendants inherit the ACP leader's process group even after + // reparenting. This remains observable when PR_SET_DUMPABLE=0 makes + // environ/cwd unreadable, so never remove their credential-bearing root. + if (processGroupId + && procStat?.processGroupId === processGroupId + && procStat.state !== "Z" && procStat.state !== "X") { + return true; + } + // A descendant may create another pgrp but cannot silently leave its + // supervisor's session without setsid(). Retain the credential root for + // every live member of the durable session receipt as well. + if (sessionId + && procStat?.sessionId === sessionId + && procStat.state !== "Z" && procStat.state !== "X") { + return true; + } try { if (statSync(`/proc/${entry}`).uid !== uid) continue; } catch (error: any) { @@ -806,6 +879,36 @@ function parseLaunchOwnerMarker(path: string): LaunchOwnerMarker | undefined { } } +function parseLaunchChildMarker(path: string): LaunchChildMarker | undefined { + try { + const source = readPrivateRegularFile(path, "OpenCode launch child marker"); + if (source === undefined) return undefined; + const parsed = JSON.parse(source) as Partial; + if (parsed.format !== OPENCODE_LAUNCH_CHILD_FORMAT + || !Number.isSafeInteger(parsed.childPid) || (parsed.childPid ?? 0) <= 0 + || !(parsed.childProcessIdentity === null || typeof parsed.childProcessIdentity === "string") + || !Number.isSafeInteger(parsed.processGroupId) || (parsed.processGroupId ?? 0) <= 0 + || parsed.processGroupId !== parsed.childPid + || !Number.isSafeInteger(parsed.sessionId) || (parsed.sessionId ?? 0) <= 0 + || parsed.sessionId !== parsed.childPid + || typeof parsed.launchDev !== "string" || !/^\d+$/.test(parsed.launchDev) + || typeof parsed.launchIno !== "string" || !/^\d+$/.test(parsed.launchIno)) { + return undefined; + } + return parsed as LaunchChildMarker; + } catch { + return undefined; + } +} + +function childMarkerMatchesLaunchNamespace( + marker: LaunchChildMarker, + launchStat: { dev: number | bigint; ino: number | bigint }, +): boolean { + return marker.launchDev === String(launchStat.dev) + && marker.launchIno === String(launchStat.ino); +} + function markerMatchesLaunchNamespace( marker: LaunchOwnerMarker, launchRoot: string, @@ -891,6 +994,26 @@ function releaseTrackedLaunchRoot( ): boolean { const tracked = trackedLaunchRoots.get(launchRoot); if (!tracked) return !lstatIfPresent(launchRoot); + const exitedProcessGroupId = exitedProcess?.processGroupId; + const exitedSessionId = exitedProcess?.sessionId; + if (exitedProcessGroupId !== undefined && exitedProcessGroupId !== null) { + if (!Number.isSafeInteger(exitedProcessGroupId) || exitedProcessGroupId <= 0) return false; + if (tracked.processGroupId !== undefined + && tracked.processGroupId !== exitedProcessGroupId) return false; + // Keep the binding after deferred cleanup so a later same-process sweep + // still recognizes an opaque descendant. + tracked.processGroupId = exitedProcessGroupId; + } + if (exitedSessionId !== undefined && exitedSessionId !== null) { + if (!Number.isSafeInteger(exitedSessionId) || exitedSessionId <= 0) return false; + if (tracked.sessionId !== undefined && tracked.sessionId !== exitedSessionId) return false; + tracked.sessionId = exitedSessionId; + } + if (unspawned && (tracked.processGroupId !== undefined || tracked.sessionId !== undefined)) { + // Once a real child inherited the tree, the no-child discard path must + // never authorize deletion. + return false; + } tracked.active = false; // The ACP child may have left a tool subprocess behind. Preserve the tree // while any live descendant still carries its launch-scoped XDG roots; a @@ -900,6 +1023,8 @@ function releaseTrackedLaunchRoot( launchRoot, tracked.safeWorkspace ? tracked.effectiveCwd : undefined, exitedProcess, + tracked.processGroupId, + tracked.sessionId, ); // Unknown procfs state is not proof that the credential-bearing tree is // unused. Only a complete negative scan authorizes deletion. @@ -936,6 +1061,13 @@ function cleanupStaleLaunchRoots(launchBase: string): void { if (tracked?.active && matchesTrackedInode) continue; const marker = parseLaunchOwnerMarker(join(launchRoot, OPENCODE_LAUNCH_OWNER_FILE)); + const childMarkerPath = join(launchRoot, OPENCODE_LAUNCH_CHILD_FILE); + const childMarkerEntry = lstatIfPresent(childMarkerPath); + const childMarker = parseLaunchChildMarker(childMarkerPath); + // A planted/tampered child marker is not deletion authority. It may only + // defer cleanup of this unpredictable 0700 root. + if (childMarkerEntry + && (!childMarker || !childMarkerMatchesLaunchNamespace(childMarker, before))) continue; // An inactive, same-inode in-memory binding is stronger than the marker's // live parent PID: it records that this process already observed child // exit/build failure. Let the sweep retry a transient removal failure. @@ -946,7 +1078,25 @@ function cleanupStaleLaunchRoots(launchBase: string): void { const inferredWorkspace = lstatIfPresent(join(launchRoot, "workspace")) ? join(launchRoot, "workspace") : undefined; - const referenced = launchRootReferencedByLiveProcess(launchRoot, inferredWorkspace); + if (matchesTrackedInode && tracked?.processGroupId !== undefined + && childMarker?.processGroupId !== undefined + && tracked.processGroupId !== childMarker.processGroupId) continue; + if (matchesTrackedInode && tracked?.sessionId !== undefined + && childMarker?.sessionId !== undefined + && tracked.sessionId !== childMarker.sessionId) continue; + const processGroupId = matchesTrackedInode + ? tracked?.processGroupId ?? childMarker?.processGroupId + : childMarker?.processGroupId; + const sessionId = matchesTrackedInode + ? tracked?.sessionId ?? childMarker?.sessionId + : childMarker?.sessionId; + const referenced = launchRootReferencedByLiveProcess( + launchRoot, + inferredWorkspace, + undefined, + processGroupId, + sessionId, + ); if (referenced === true) continue; const createdAt = marker?.createdAtMs ?? before.mtimeMs; @@ -984,6 +1134,85 @@ export function cleanupOpencodeChildEnv( return releaseTrackedLaunchRoot(launchRoot, exitedProcess); } +/** + * Durably bind a successfully spawned detached ACP child to its launch root. + * Cleanup can then recognize opaque surviving descendants by process group, + * including after agent-node itself crashes and a later process sweeps roots. + * Call immediately after spawn(), before the first await. + */ +export function bindOpencodeChildProcessGroup( + workDirInput: string, + env: NodeJS.ProcessEnv, + child: OpencodeSpawnedProcessIdentity, +): void { + const workDir = resolve(workDirInput); + const dataRoot = env.XDG_DATA_HOME; + if (typeof dataRoot !== "string") { + throw new Error("opencode refuses child binding: launch data root is missing"); + } + const launchRoot = dirname(resolve(dataRoot)); + if (resolve(dataRoot) !== join(launchRoot, "data")) { + throw new Error("opencode refuses child binding: launch data root changed"); + } + const tracked = trackedLaunchRoots.get(launchRoot); + if (!tracked || !tracked.active || tracked.workDir !== workDir) { + throw new Error("opencode refuses child binding: launch root is not active"); + } + const processGroupId = child.processGroupId; + const sessionId = child.sessionId; + if (process.platform === "win32" || !Number.isSafeInteger(child.pid) || child.pid <= 0 + || !Number.isSafeInteger(processGroupId) || (processGroupId ?? 0) <= 0 + || processGroupId !== child.pid + || !Number.isSafeInteger(sessionId) || (sessionId ?? 0) <= 0 + || sessionId !== child.pid) { + throw new Error("opencode refuses child binding: expected a dedicated Linux session/group leader"); + } + if (!(child.identity === null || typeof child.identity === "string")) { + throw new Error("opencode refuses child binding: invalid child process identity"); + } + + const base = lstatIfPresent(tracked.basePath); + const launch = lstatIfPresent(launchRoot); + if (!base || !sameIdentity(base, { dev: tracked.baseDev, ino: tracked.baseIno }) + || !launch || !sameIdentity(launch, tracked)) { + throw new Error("opencode refuses child binding: launch namespace changed"); + } + assertPrivateDirectory(launchRoot, "fresh OpenCode launch root", true); + + const stat = readLinuxProcessStat(child.pid); + if (stat) { + if (stat.processGroupId !== processGroupId + || stat.sessionId !== sessionId + || (child.identity !== null && stat.identity !== child.identity) + || stat.state === "Z" || stat.state === "X") { + throw new Error("opencode refuses child binding: spawned process identity changed"); + } + } else if (!isProcessGroupAlive(processGroupId)) { + throw new Error("opencode refuses child binding: spawned process group is not live"); + } + + const marker: LaunchChildMarker = { + format: OPENCODE_LAUNCH_CHILD_FORMAT, + childPid: child.pid, + childProcessIdentity: child.identity, + processGroupId, + sessionId: sessionId as number, + launchDev: String(tracked.dev), + launchIno: String(tracked.ino), + }; + atomicWritePrivateFile( + join(launchRoot, OPENCODE_LAUNCH_CHILD_FILE), + JSON.stringify(marker) + "\n", + "OpenCode launch child marker", + ); + const after = lstatIfPresent(launchRoot); + if (!after || !sameIdentity(after, tracked)) { + throw new Error("opencode refuses child binding: launch root changed after marker write"); + } + tracked.processGroupId = processGroupId; + tracked.sessionId = sessionId as number; +} + /** Remove a launch tree after binary resolution/spawn failed before a child * could inherit it. This narrow API never performs a live-process exemption. */ export function discardUnspawnedOpencodeChildEnv( diff --git a/agent-node/src/runtime/opencode-acp/client.test.ts b/agent-node/src/runtime/opencode-acp/client.test.ts index 6e35dfc6..659641ff 100644 --- a/agent-node/src/runtime/opencode-acp/client.test.ts +++ b/agent-node/src/runtime/opencode-acp/client.test.ts @@ -10,7 +10,8 @@ // (tests/test-rfc029-pr2-acp-shim/). import { describe, expect, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { once } from "events"; import { join } from "path"; import { tmpdir } from "os"; import { OpencodeAcpClient } from "./client"; @@ -31,6 +32,25 @@ function stubEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { return { PATH: process.env.PATH, ...extra }; } +async function waitForFixture(predicate: () => boolean, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error("timed out waiting for client lifecycle fixture"); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 20)); + } +} + +function pidIsLive(pid: number): boolean { + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const close = stat.lastIndexOf(")"); + const state = close < 0 ? "" : stat.slice(close + 1).trim().split(/\s+/)[0]; + return state !== "Z" && state !== "X"; + } catch { + return false; + } +} + describe("OpencodeAcpClient — request/response correlation", () => { test("request() resolves with the matching response's result", async () => { const stub = makeStubBinary(` @@ -49,6 +69,7 @@ describe("OpencodeAcpClient — request/response correlation", () => { `); const c = new OpencodeAcpClient(); c.start({ binary: stub, env: stubEnv() }); + await c.activate(); try { const r = await c.request<{ echoed: string }>("initialize", { protocolVersion: 1 }, 5000); expect(r.echoed).toBe("initialize"); @@ -72,6 +93,7 @@ describe("OpencodeAcpClient — request/response correlation", () => { `); const c = new OpencodeAcpClient(); c.start({ binary: stub, env: stubEnv() }); + await c.activate(); try { let thrown: Error | null = null; try { await c.request("session/new", {}, 3000); } @@ -106,6 +128,7 @@ describe("OpencodeAcpClient — streaming notifications", () => { const notifications: any[] = []; c.on("notification", (n) => notifications.push(n)); c.start({ binary: stub, env: stubEnv() }); + await c.activate(); try { const result = await c.request<{ stopReason: string }>("session/prompt", {}, 5000); expect(result.stopReason).toBe("end_turn"); @@ -153,6 +176,7 @@ describe("OpencodeAcpClient — streaming notifications", () => { c.on("serverRequest", (request) => reverseRequests.push(request)); c.on("notification", (notification) => notifications.push(notification)); c.start({ binary: stub, env: stubEnv() }); + await c.activate(); try { const result = await c.request<{ reverseError: { code: number; message: string } }>( "initialize", {}, 5000, @@ -169,6 +193,31 @@ describe("OpencodeAcpClient — streaming notifications", () => { }); describe("OpencodeAcpClient — process lifecycle", () => { + test("supervisor receipt is ready before the vendor can inherit launch state", async () => { + if (process.platform !== "linux") return; + const startedFile = join(tmpdir(), `opencode-client-started-${process.pid}-${Date.now()}`); + const stub = makeStubBinary(` + import { writeFileSync } from "fs"; + writeFileSync(process.env.STARTED_FILE, String(process.pid)); + process.stdin.resume(); + `); + const c = new OpencodeAcpClient(); + c.start({ binary: stub, env: stubEnv({ STARTED_FILE: startedFile }) }); + try { + const receipt = await c.prepare(); + expect(receipt).toBeDefined(); + expect(receipt!.pid).toBe(c.processId!); + expect(receipt!.processGroupId).toBe(receipt!.pid); + expect(receipt!.sessionId).toBe(receipt!.pid); + expect(existsSync(startedFile)).toBe(false); + await c.activate(); + await waitForFixture(() => existsSync(startedFile)); + } finally { + await c.stop("SIGKILL").catch(() => {}); + rmSync(startedFile, { force: true }); + } + }); + test("child exit rejects all pending requests", async () => { // Stub that reads one line then exits without responding. const stub = makeStubBinary(` @@ -182,6 +231,7 @@ describe("OpencodeAcpClient — process lifecycle", () => { `); const c = new OpencodeAcpClient(); c.start({ binary: stub, env: stubEnv() }); + await c.activate(); let thrown: Error | null = null; try { await c.request("session/prompt", {}, 5000); @@ -197,11 +247,151 @@ describe("OpencodeAcpClient — process lifecycle", () => { `); const c = new OpencodeAcpClient(); c.start({ binary: stub, env: stubEnv() }); + await c.activate(); expect(c.isRunning).toBe(true); await c.stop(); expect(c.isRunning).toBe(false); }); + test("a crashing group leader cannot leave a live descendant behind", async () => { + if (process.platform !== "linux") return; + const childPidFile = join(tmpdir(), `opencode-client-child-${process.pid}-${Date.now()}`); + const stub = makeStubBinary(` + import { spawn } from "child_process"; + import { writeFileSync } from "fs"; + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdio: "ignore", + }); + child.unref(); + writeFileSync(process.env.CHILD_PID_FILE, String(child.pid)); + setTimeout(() => process.exit(23), 50); + `); + const c = new OpencodeAcpClient(); + const exited = once(c, "exit"); + c.start({ binary: stub, env: stubEnv({ CHILD_PID_FILE: childPidFile }) }); + await c.activate(); + try { + await waitForFixture(() => existsSync(childPidFile)); + const childPid = Number(readFileSync(childPidFile, "utf8")); + expect(Number.isSafeInteger(childPid) && childPid > 0).toBe(true); + await exited; + await waitForFixture(() => { + try { + const stat = readFileSync(`/proc/${childPid}/stat`, "utf8"); + const close = stat.lastIndexOf(")"); + const state = close < 0 ? "" : stat.slice(close + 1).trim().split(/\s+/)[0]; + return state === "Z" || state === "X"; + } catch { + return true; + } + }); + expect(c.isRunning).toBe(false); + } finally { + await c.stop("SIGKILL").catch(() => {}); + rmSync(childPidFile, { force: true }); + } + }); + + test("SIGSTOP supervisor makes stop fail closed until the exact owner resumes", async () => { + if (process.platform !== "linux") return; + const vendorPidFile = join(tmpdir(), `opencode-client-vendor-${process.pid}-${Date.now()}`); + const stub = makeStubBinary(` + import { writeFileSync } from "fs"; + writeFileSync(process.env.VENDOR_PID_FILE, String(process.pid)); + process.stdin.resume(); + `); + const c = new OpencodeAcpClient(); + c.start({ binary: stub, env: stubEnv({ VENDOR_PID_FILE: vendorPidFile }) }); + await c.activate(); + const supervisorPid = c.processId!; + await waitForFixture(() => existsSync(vendorPidFile)); + const vendorPid = Number(readFileSync(vendorPidFile, "utf8")); + process.kill(supervisorPid, "SIGSTOP"); + try { + let stopError: Error | null = null; + try { await c.stop("SIGTERM", 200); } + catch (error: any) { stopError = error; } + expect(stopError?.message).toContain("owner tree retained"); + expect(c.cleanupConfirmed).toBe(false); + expect(c.isRunning).toBe(true); + expect(pidIsLive(supervisorPid)).toBe(true); + expect(pidIsLive(vendorPid)).toBe(true); + + const exited = once(c, "exit"); + process.kill(supervisorPid, "SIGCONT"); + await exited; + expect(c.cleanupConfirmed).toBe(true); + await waitForFixture(() => !pidIsLive(vendorPid)); + } finally { + try { process.kill(supervisorPid, "SIGCONT"); } catch {} + await c.stop("SIGKILL", 2_000).catch(() => {}); + rmSync(vendorPidFile, { force: true }); + } + }); + + test("external supervisor SIGKILL never triggers a stale PGID kill or clean exit", async () => { + if (process.platform !== "linux" || !existsSync("/usr/bin/python3")) return; + const vendorPidFile = join(tmpdir(), `opencode-client-external-kill-${process.pid}-${Date.now()}`); + const stub = makeStubBinary(` + import { spawn } from "child_process"; + import { writeFileSync } from "fs"; + const python = [ + "import ctypes, signal, time", + "assert ctypes.CDLL(None).prctl(1, 0, 0, 0, 0) == 0", + "signal.signal(signal.SIGHUP, signal.SIG_IGN)", + "signal.signal(signal.SIGTERM, signal.SIG_IGN)", + "time.sleep(60)", + ].join("\\n"); + const survivor = spawn("python3", ["-c", python], { stdio: "ignore" }); + survivor.unref(); + writeFileSync(process.env.VENDOR_PID_FILE, String(survivor.pid)); + setInterval(() => {}, 1000); + `); + const c = new OpencodeAcpClient(); + let publicExitCount = 0; + c.on("exit", () => { publicExitCount += 1; }); + c.start({ binary: stub, env: stubEnv({ VENDOR_PID_FILE: vendorPidFile }) }); + await c.activate(); + await waitForFixture(() => existsSync(vendorPidFile)); + const vendorPid = Number(readFileSync(vendorPidFile, "utf8")); + const directVendorPid = c.vendorProcessId!; + const cleanupFailed = once(c, "cleanupError"); + process.kill(c.processId!, "SIGKILL"); + try { + await cleanupFailed; + expect(pidIsLive(vendorPid)).toBe(true); + expect(c.cleanupConfirmed).toBe(false); + expect(publicExitCount).toBe(0); + } finally { + // Test-only exact PID cleanup. Production deliberately retains and + // reports this external-SIGKILL boundary instead of targeting old PGID. + try { process.kill(vendorPid, "SIGKILL"); } catch {} + try { process.kill(directVendorPid, "SIGKILL"); } catch {} + await waitForFixture(() => !pidIsLive(vendorPid)); + await waitForFixture(() => !pidIsLive(directVendorPid)); + await c.stop("SIGTERM", 2_000).catch(() => {}); + rmSync(vendorPidFile, { force: true }); + } + expect(c.cleanupConfirmed).toBe(true); + expect(publicExitCount).toBe(1); + }); + + test("concurrent stop callers share one verified public exit", async () => { + if (process.platform !== "linux") return; + const stub = makeStubBinary(`process.stdin.resume();`); + const c = new OpencodeAcpClient(); + let exitCount = 0; + c.on("exit", () => { exitCount += 1; }); + c.start({ binary: stub, env: stubEnv() }); + await c.activate(); + await Promise.all([ + c.stop("SIGTERM", 3_000), + c.stop("SIGKILL", 3_000), + ]); + expect(c.cleanupConfirmed).toBe(true); + expect(exitCount).toBe(1); + }); + test("explicit child env is not merged with the client's process.env", async () => { const stub = makeStubBinary(` let buf = ""; @@ -222,6 +412,7 @@ describe("OpencodeAcpClient — process lifecycle", () => { `); const c = new OpencodeAcpClient(); c.start({ binary: stub, env: stubEnv({ SAFE_MARKER: "present" }) }); + await c.activate(); try { const result = await c.request<{ home: string | null; commhub: string | null; marker: string }>( "initialize", {}, 5000, diff --git a/agent-node/src/runtime/opencode-acp/client.ts b/agent-node/src/runtime/opencode-acp/client.ts index 7b83f0aa..c765b3ab 100644 --- a/agent-node/src/runtime/opencode-acp/client.ts +++ b/agent-node/src/runtime/opencode-acp/client.ts @@ -24,6 +24,9 @@ import { spawn, type ChildProcessWithoutNullStreams } from "child_process"; import { EventEmitter } from "events"; +import { readdirSync, readFileSync } from "fs"; +import { isAbsolute } from "path"; +import { OPENCODE_GROUP_SUPERVISOR_SOURCE } from "./group-supervisor"; export interface JsonRpcRequest

{ jsonrpc: "2.0"; @@ -78,12 +81,93 @@ export interface OpencodeAcpExitInfo { cause?: Error; } +export interface OpencodeSupervisorReceipt { + pid: number; + identity: string; + processGroupId: number; + sessionId: number; +} + +type ClientLifecycle = + | "starting" + | "running" + | "stopping" + | "finalizing" + | "cleanup-failed" + | "closed"; + +interface LinuxProcessStat { + identity: string; + state: string; + processGroupId: number; + sessionId: number; +} + +function readLinuxProcessStat(pid: number): LinuxProcessStat | undefined { + if (process.platform !== "linux") return undefined; + try { + const source = readFileSync(`/proc/${pid}/stat`, "utf8"); + const close = source.lastIndexOf(")"); + if (close < 0) return undefined; + const fields = source.slice(close + 1).trim().split(/\s+/); + const processGroupId = Number(fields[2]); + const sessionId = Number(fields[3]); + const start = fields[19]; + if (!fields[0] || !start + || !Number.isSafeInteger(processGroupId) || processGroupId <= 0 + || !Number.isSafeInteger(sessionId) || sessionId <= 0) return undefined; + return { + identity: `${pid}:${start}`, + state: fields[0], + processGroupId, + sessionId, + }; + } catch { + return undefined; + } +} + +function inspectSupervisorSession( + receipt: OpencodeSupervisorReceipt, +): "gone" | "residual" | "unknown" { + if (process.platform !== "linux") return "unknown"; + const currentLeader = readLinuxProcessStat(receipt.pid); + if (currentLeader && currentLeader.identity !== receipt.identity) { + // The PID/SID number cannot be reused while the old session still has a + // member. A different starttime therefore proves the captured session is + // gone; critically, it is never a reason to signal the new process. + return "gone"; + } + let entries: string[]; + try { entries = readdirSync("/proc"); } + catch { return "unknown"; } + for (const entry of entries) { + if (!/^\d+$/.test(entry)) continue; + const stat = readLinuxProcessStat(Number(entry)); + if (!stat || stat.state === "Z" || stat.state === "X") continue; + if (stat.sessionId === receipt.sessionId + || stat.processGroupId === receipt.processGroupId) return "residual"; + } + return "gone"; +} + export class OpencodeAcpClient extends EventEmitter { private child: ChildProcessWithoutNullStreams | null = null; private nextId = 1; private pending = new Map void; reject: (e: Error) => void }>(); private rxBuffer = ""; - private closed = false; + private lifecycle: ClientLifecycle = "closed"; + private useGroupSupervisor = false; + private supervisorReceipt: OpencodeSupervisorReceipt | null = null; + private pinnedBinary = ""; + private vendorStarted = false; + private vendorProcessIdValue: number | undefined; + private vendorExitInfo: OpencodeAcpExitInfo | null = null; + private nativeExitInfo: OpencodeAcpExitInfo | null = null; + private finalizationPromise: Promise | null = null; + private lastCleanupError: Error | null = null; + private publicExitEmitted = false; + private supervisorWatchTimer: ReturnType | null = null; private lastIncomingAt = Date.now(); /** Timestamp (ms since epoch) of the last JSON-RPC frame received @@ -100,41 +184,112 @@ export class OpencodeAcpClient extends EventEmitter { return this.child?.pid; } + /** Dedicated Linux process group owned by the durable supervisor anchor. */ + get processGroupId(): number | undefined { + return this.supervisorReceipt?.processGroupId; + } + + /** Dedicated Linux session. Descendants that change pgrp but not session + * remain visible to fail-closed launch-root cleanup. */ + get sessionId(): number | undefined { + return this.supervisorReceipt?.sessionId; + } + + /** Diagnostic/test receipt only. Lifecycle control must stay on supervisor + * IPC and must never signal this numeric PID from the parent. */ + get vendorProcessId(): number | undefined { + return this.vendorProcessIdValue; + } + + get cleanupConfirmed(): boolean { + return this.lifecycle === "closed"; + } + + get cleanupError(): Error | null { + return this.lastCleanupError; + } + start(opts: OpencodeAcpClientOptions = {}): void { if (this.child) throw new Error("OpencodeAcpClient already started"); const bin = opts.binary ?? "opencode"; // NOTE: `opencode acp` v1.17.13 IGNORES --port/--hostname (see // Phase 0b U8 finding — flags are accepted for CLI parsing but // the server binds to stdio only). Do not pass them here. - this.child = spawn(bin, ["acp"], { + this.pinnedBinary = bin; + this.useGroupSupervisor = process.platform === "linux"; + this.lifecycle = "starting"; + this.child = spawn( + this.useGroupSupervisor ? process.execPath : bin, + this.useGroupSupervisor ? ["-e", OPENCODE_GROUP_SUPERVISOR_SOURCE] : ["acp"], + { cwd: opts.cwd ?? process.cwd(), // `spawn()` inherits process.env when `env` is undefined. Use an empty // object as the lower-level default so a caller can never accidentally // leak agent-node's CommHub/channel/MCP credentials. runtime.ts always // supplies the exact allowlisted environment built in child-env.ts. env: opts.env ?? {}, - stdio: ["pipe", "pipe", "pipe"], - }); + stdio: this.useGroupSupervisor + ? ["pipe", "pipe", "pipe", "ipc"] + : ["pipe", "pipe", "pipe"], + // Linux supervisor pid=pgid=sid is the stable identity anchor. OpenCode + // itself is launched only after its marker is durable. + detached: this.useGroupSupervisor, + }) as ChildProcessWithoutNullStreams; this.child.stdout.setEncoding("utf8"); this.child.stdout.on("data", (chunk: string) => this.onStdout(chunk)); this.child.stderr.setEncoding("utf8"); this.child.stderr.on("data", (chunk: string) => this.emit("stderr", chunk)); + if (this.useGroupSupervisor) { + this.child.on("message", (message) => this.onSupervisorMessage(message)); + this.child.on("disconnect", () => this.onSupervisorDisconnect()); + } else { + this.lifecycle = "running"; + this.vendorStarted = true; + } this.child.on("exit", (code, signal) => { - this.finalizeExit(code, signal); + void this.finalizeExit(code, signal); }); this.child.on("error", (err) => { // A spawn failure may emit `error` without `exit`. Finalize first so // stop() and any handshake request cannot hang forever. EventEmitter's // special `error` event throws when unobserved, so surface it only when // the caller explicitly subscribed (runtime.ts does). - this.finalizeExit(null, null, err); + void this.finalizeExit(null, null, err); if (this.listenerCount("error") > 0) this.emit("error", err); }); } + /** Wait for and independently validate the supervisor identity. OpenCode is + * still not spawned when this resolves. */ + async prepare(timeoutMs = 5_000): Promise { + if (!this.useGroupSupervisor) return undefined; + if (this.supervisorReceipt) return this.supervisorReceipt; + await this.waitForInternalEvent("supervisorReady", timeoutMs); + if (!this.supervisorReceipt) throw new Error("OpenCode supervisor exited before ready receipt"); + return this.supervisorReceipt; + } + + /** Launch OpenCode only after runtime.ts has fsync'd the receipt-bound + * launch marker. */ + async activate(timeoutMs = 5_000): Promise { + if (!this.useGroupSupervisor) return; + if (this.vendorStarted && this.lifecycle === "running") return; + await this.prepare(timeoutMs); + if (!isAbsolute(this.pinnedBinary)) { + throw new Error("OpenCode supervisor requires an absolute pinned binary path"); + } + await this.sendSupervisor({ v: 1, type: "launch", binary: this.pinnedBinary }); + if (!this.vendorStarted) await this.waitForInternalEvent("childStarted", timeoutMs); + if (!this.vendorStarted || this.lifecycle !== "running") { + throw this.lastCleanupError ?? new Error("OpenCode supervisor failed before child start"); + } + } + async request(method: string, params?: P, timeoutMs = 30_000): Promise { - if (!this.child || this.closed) throw new Error("OpencodeAcpClient not started or already exited"); + if (!this.child || this.lifecycle !== "running") { + throw this.lastCleanupError ?? new Error("OpencodeAcpClient not ready or already exited"); + } const id = this.nextId++; const payload: JsonRpcRequest

= { jsonrpc: "2.0", id, method, @@ -171,7 +326,9 @@ export class OpencodeAcpClient extends EventEmitter { params: P, idleTimeoutMs: number, ): Promise { - if (!this.child || this.closed) throw new Error("OpencodeAcpClient not started or already exited"); + if (!this.child || this.lifecycle !== "running") { + throw this.lastCleanupError ?? new Error("OpencodeAcpClient not ready or already exited"); + } const id = this.nextId++; const payload: JsonRpcRequest

= { jsonrpc: "2.0", id, method, params }; const sentAt = Date.now(); @@ -204,27 +361,35 @@ export class OpencodeAcpClient extends EventEmitter { }); } - /** Terminate the child and refuse further requests. Idempotent. */ - async stop(signal: NodeJS.Signals = "SIGTERM"): Promise { - if (!this.child || this.closed) return; - await new Promise((resolve, reject) => { - const onExit = () => resolve(); - // Subscribe before kill(): a very short-lived child can otherwise exit - // between kill() and once(), leaving shutdown waiting forever. - this.once("exit", onExit); - if (this.closed) { - this.off("exit", onExit); - resolve(); - return; - } - try { - this.child!.kill(signal); - } catch (error) { - this.off("exit", onExit); - if (this.closed) resolve(); - else reject(error); - } + /** + * Stop through the supervisor's exact IPC channel. The parent never sends a + * negative-PGID signal and never SIGKILLs the anchor. A stopped/wedged + * supervisor therefore times out while retaining the whole owner tree. + */ + async stop(signal: NodeJS.Signals = "SIGTERM", timeoutMs = 5_000): Promise { + if (!this.child || this.lifecycle === "closed") return; + if (!this.useGroupSupervisor) { + if (this.lifecycle !== "finalizing") this.lifecycle = "stopping"; + this.child.kill(signal); + await this.waitForPublicExit(timeoutMs); + return; + } + + if (this.nativeExitInfo) { + await this.retryFinalization(); + if (this.lifecycle === "closed") return; + throw this.lastCleanupError ?? new Error("OpenCode supervisor cleanup remains unconfirmed"); + } + + this.lastCleanupError = null; + this.lifecycle = "stopping"; + await this.sendSupervisor({ + v: 1, + type: "stop", + mode: signal === "SIGKILL" ? "force" : "graceful", + signal: signal === "SIGINT" ? "SIGINT" : "SIGTERM", }); + await this.waitForPublicExit(timeoutMs); } /** @@ -232,23 +397,269 @@ export class OpencodeAcpClient extends EventEmitter { * Used by runtime.ts's crash-restart detector. */ get isRunning(): boolean { - return this.child !== null && !this.closed; + return this.child !== null + && (this.lifecycle === "starting" + || this.lifecycle === "running" + || this.lifecycle === "stopping"); } - private finalizeExit( + private async finalizeExit( code: number | null, signal: NodeJS.Signals | null, cause?: Error, - ): void { - if (this.closed) return; - this.closed = true; + ): Promise { + if (this.nativeExitInfo || this.lifecycle === "closed") return; + this.nativeExitInfo = { code, signal, ...(cause ? { cause } : {}) }; + if (this.supervisorWatchTimer) clearInterval(this.supervisorWatchTimer); + this.supervisorWatchTimer = null; + this.lifecycle = "finalizing"; const err = cause ?? new Error(`opencode acp exited (code=${code} signal=${signal})`); - for (const [, pending] of this.pending) pending.reject(err); - this.pending.clear(); - const info: OpencodeAcpExitInfo = { code, signal, ...(cause ? { cause } : {}) }; + this.rejectPending(err); + + if (!this.useGroupSupervisor || !this.supervisorReceipt) { + this.completeCleanExit(this.vendorExitInfo ?? this.nativeExitInfo); + return; + } + await this.retryFinalization().catch(() => { + // Native exit handlers cannot surface an async rejection. The separate + // cleanupError event and retained lifecycle state are the fail-closed + // public result; an explicit stop() retry still receives the error. + }); + } + + private async retryFinalization(): Promise { + if (this.lifecycle === "closed") return; + if (!this.nativeExitInfo || !this.supervisorReceipt) { + throw this.lastCleanupError ?? new Error("OpenCode supervisor is still live"); + } + if (this.finalizationPromise) return this.finalizationPromise; + this.lifecycle = "finalizing"; + this.finalizationPromise = (async () => { + const deadline = Date.now() + 1_500; + let state: "gone" | "residual" | "unknown" = "unknown"; + do { + state = inspectSupervisorSession(this.supervisorReceipt!); + if (state === "gone") { + this.lastCleanupError = null; + this.completeCleanExit(this.vendorExitInfo ?? this.nativeExitInfo!); + return; + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 25)); + } while (Date.now() < deadline); + throw new Error( + state === "unknown" + ? "cannot verify that the OpenCode supervisor session is empty" + : `OpenCode supervisor exited with live session ${this.supervisorReceipt!.sessionId} members`, + ); + })().catch((error: any) => { + this.lifecycle = "cleanup-failed"; + this.lastCleanupError = error instanceof Error ? error : new Error(String(error)); + this.emit("cleanupError", this.lastCleanupError); + throw this.lastCleanupError; + }).finally(() => { + this.finalizationPromise = null; + }); + return this.finalizationPromise; + } + + private completeCleanExit(info: OpencodeAcpExitInfo): void { + if (this.publicExitEmitted) return; + this.lifecycle = "closed"; + this.lastCleanupError = null; + this.publicExitEmitted = true; + if (this.supervisorWatchTimer) clearInterval(this.supervisorWatchTimer); + this.supervisorWatchTimer = null; this.emit("exit", info); } + private rejectPending(error: Error): void { + for (const [, pending] of this.pending) pending.reject(error); + this.pending.clear(); + } + + private onSupervisorMessage(message: unknown): void { + if (!message || typeof message !== "object") return; + const value = message as Record; + if (value.v !== 1 || typeof value.type !== "string") return; + if (value.type === "supervisor-ready") { + const pid = value.pid; + const processGroupId = value.processGroupId; + const sessionId = value.sessionId; + const identity = value.identity; + const expectedPid = this.child?.pid; + const stat = typeof pid === "number" ? readLinuxProcessStat(pid) : undefined; + if (!Number.isSafeInteger(pid) || pid !== expectedPid + || typeof identity !== "string" + || processGroupId !== pid || sessionId !== pid + || !stat || stat.identity !== identity + || stat.processGroupId !== processGroupId || stat.sessionId !== sessionId + || stat.state === "Z" || stat.state === "X") { + this.lastCleanupError = new Error("invalid OpenCode supervisor identity receipt"); + this.emit("supervisorFatal", this.lastCleanupError); + this.child?.disconnect?.(); + return; + } + this.supervisorReceipt = { + pid, + identity, + processGroupId: processGroupId as number, + sessionId: sessionId as number, + }; + // Bun can defer ChildProcess exit/disconnect while a surviving vendor + // still holds inherited fd0/1/2. Procfs identity polling detects an + // externally killed anchor without relying on those shared pipes. + this.supervisorWatchTimer = setInterval(() => { + if (this.nativeExitInfo || this.lifecycle === "closed") return; + const current = readLinuxProcessStat(pid); + if (!current || current.identity !== identity) { + void this.finalizeExit( + null, + null, + new Error("OpenCode supervisor identity disappeared before native exit event"), + ); + } + }, 50); + this.emit("supervisorReady"); + return; + } + if (value.type === "child-started") { + if (!Number.isSafeInteger(value.childPid) || (value.childPid as number) <= 0) { + this.lastCleanupError = new Error("invalid OpenCode child-started receipt"); + this.emit("supervisorFatal", this.lastCleanupError); + return; + } + this.vendorStarted = true; + this.vendorProcessIdValue = value.childPid as number; + this.lifecycle = "running"; + this.emit("childStarted"); + return; + } + if (value.type === "vendor-exit") { + this.vendorExitInfo = { + code: typeof value.code === "number" ? value.code : null, + signal: typeof value.signal === "string" ? value.signal as NodeJS.Signals : null, + }; + return; + } + if (value.type === "cleanup-failed") { + const error = new Error( + `OpenCode supervisor retained its owner tree: ${String(value.reason ?? "cleanup failed")}`, + ); + this.lifecycle = "cleanup-failed"; + this.lastCleanupError = error; + this.rejectPending(error); + this.emit("cleanupError", error); + return; + } + if (value.type === "fatal") { + const error = new Error( + `OpenCode supervisor ${String(value.phase ?? "fatal")}: ${String(value.message ?? "unknown error")}`, + ); + this.lastCleanupError = error; + this.rejectPending(error); + this.emit("supervisorFatal", error); + } + } + + private onSupervisorDisconnect(): void { + const receipt = this.supervisorReceipt; + if (!this.useGroupSupervisor || !receipt || this.nativeExitInfo) return; + // Bun may defer ChildProcess `exit` until inherited fd0/1/2 close. The + // private IPC descriptor is not inherited by OpenCode, so disconnect is + // the prompt supervisor-death signal. Poll the captured starttime before + // finalizing; never infer identity from a bare numeric PID. + const deadline = Date.now() + 2_000; + const poll = () => { + if (this.nativeExitInfo) return; + const current = readLinuxProcessStat(receipt.pid); + if (!current || current.identity !== receipt.identity) { + void this.finalizeExit( + null, + null, + new Error("OpenCode supervisor IPC disconnected before native exit event"), + ); + return; + } + if (Date.now() >= deadline) { + const error = new Error("OpenCode supervisor IPC disconnected while its identity remains live"); + this.lifecycle = "cleanup-failed"; + this.lastCleanupError = error; + this.rejectPending(error); + this.emit("cleanupError", error); + return; + } + setTimeout(poll, 25); + }; + poll(); + } + + private async sendSupervisor(message: Record): Promise { + const child = this.child; + if (!child || !child.connected || typeof child.send !== "function") { + throw this.lastCleanupError ?? new Error("OpenCode supervisor IPC channel is closed"); + } + await new Promise((resolve, reject) => { + child.send(message, (error) => error ? reject(error) : resolve()); + }); + } + + private async waitForInternalEvent(event: string, timeoutMs: number): Promise { + const predicate = event === "supervisorReady" + ? () => this.supervisorReceipt !== null + : () => this.vendorStarted; + if (predicate()) return; + await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + this.off(event, onReady); + this.off("supervisorFatal", onFatal); + this.off("exit", onExit); + this.off("cleanupError", onFatal); + }; + const onReady = () => { cleanup(); resolve(); }; + const onFatal = (error: Error) => { cleanup(); reject(error); }; + const onExit = () => { + cleanup(); + reject(this.lastCleanupError ?? new Error(`opencode acp exited before ${event}`)); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`timed out waiting for OpenCode ${event} after ${timeoutMs}ms`)); + }, timeoutMs); + this.once(event, onReady); + this.once("supervisorFatal", onFatal); + this.once("cleanupError", onFatal); + this.once("exit", onExit); + if (predicate()) onReady(); + }); + } + + private async waitForPublicExit(timeoutMs: number): Promise { + if (this.lifecycle === "closed") return; + if (this.lifecycle === "cleanup-failed" && this.lastCleanupError) throw this.lastCleanupError; + await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + this.off("exit", onExit); + this.off("cleanupError", onFailure); + }; + const onExit = () => { cleanup(); resolve(); }; + const onFailure = (error: Error) => { cleanup(); reject(error); }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error( + `OpenCode supervisor stop timed out after ${timeoutMs}ms; owner tree retained`, + )); + }, timeoutMs); + this.once("exit", onExit); + this.once("cleanupError", onFailure); + if (this.lifecycle === "closed") onExit(); + else if (this.lifecycle === "cleanup-failed" && this.lastCleanupError) { + onFailure(this.lastCleanupError); + } + }); + } + private onStdout(chunk: string): void { this.rxBuffer += chunk; while (this.rxBuffer.includes("\n")) { @@ -294,7 +705,7 @@ export class OpencodeAcpClient extends EventEmitter { if ("id" in msg && "method" in msg && (msg as any).method) { const request = msg as JsonRpcServerRequest; this.emit("serverRequest", request); - if (this.child && !this.closed) { + if (this.child && this.lifecycle === "running") { const response = { jsonrpc: "2.0" as const, id: request.id, diff --git a/agent-node/src/runtime/opencode-acp/group-supervisor.ts b/agent-node/src/runtime/opencode-acp/group-supervisor.ts new file mode 100644 index 00000000..49427d86 --- /dev/null +++ b/agent-node/src/runtime/opencode-acp/group-supervisor.ts @@ -0,0 +1,244 @@ +/** + * Source for the Linux-only OpenCode ACP process-group supervisor. + * + * It is deliberately evaluated by the already-running, trusted + * `process.execPath` instead of being shipped as a second executable asset. + * The supervisor becomes a long-lived session/process-group leader, reports + * that identity over the private child-process IPC channel, and does not + * launch OpenCode until the parent confirms that the durable launch marker is + * on disk. No control bytes are ever written to stdout: fd 0/1/2 are inherited + * directly by the vendor process for the ACP transport. + */ +export const OPENCODE_GROUP_SUPERVISOR_SOURCE = String.raw` +"use strict"; +const { spawn } = require("node:child_process"); +const { readdirSync, readFileSync } = require("node:fs"); +const { isAbsolute } = require("node:path"); + +const GRACE_MS = 1200; +const POLL_MS = 25; +const selfPid = process.pid; +let vendor = null; +let launched = false; +let stopping = false; +let cleanupTimer = null; +let cleanupDeadline = 0; +let vendorExit = null; +let cleanupFailureReported = false; + +function readStat(pid) { + try { + const source = readFileSync("/proc/" + pid + "/stat", "utf8"); + const close = source.lastIndexOf(")"); + if (close < 0) return null; + const fields = source.slice(close + 1).trim().split(/\s+/); + const pgrp = Number(fields[2]); + const session = Number(fields[3]); + const start = fields[19]; + if (!fields[0] || !Number.isSafeInteger(pgrp) || !Number.isSafeInteger(session) || !start) { + return null; + } + return { state: fields[0], pgrp, session, identity: pid + ":" + start }; + } catch { + return null; + } +} + +function send(message, callback) { + if (typeof process.send !== "function" || !process.connected) { + if (callback) callback(new Error("parent IPC channel is closed")); + return; + } + try { + process.send(message, callback); + } catch (error) { + if (callback) callback(error); + } +} + +function sessionPeers() { + let entries; + try { entries = readdirSync("/proc"); } + catch { return { kind: "unknown", peers: [] }; } + const peers = []; + for (const entry of entries) { + if (!/^\d+$/.test(entry)) continue; + const pid = Number(entry); + if (pid === selfPid) continue; + const stat = readStat(pid); + if (!stat || stat.state === "Z" || stat.state === "X") continue; + if (stat.session === selfPid) peers.push({ pid, pgrp: stat.pgrp }); + } + return { kind: "known", peers }; +} + +function finishWithoutPeers() { + if (cleanupTimer) clearTimeout(cleanupTimer); + const code = vendorExit && Number.isInteger(vendorExit.code) + ? Math.max(0, Math.min(255, vendorExit.code)) + : (stopping ? 0 : 1); + process.exit(code); +} + +function reportCleanupFailure(reason, peers) { + if (!cleanupFailureReported) { + cleanupFailureReported = true; + send({ + v: 1, + type: "cleanup-failed", + reason, + peers: peers.map((peer) => ({ pid: peer.pid, pgrp: peer.pgrp })), + }); + } + // Keep the exact SID/PGID leader alive. A later retry may succeed after an + // opaque or alternate-pgrp descendant exits; killing the anchor would turn + // a detected residual tree into an unowned orphan. + cleanupTimer = setTimeout(checkCleanup, POLL_MS * 8); +} + +function forceOwnedGroup() { + const inspected = sessionPeers(); + if (inspected.kind !== "known") { + reportCleanupFailure("cannot inspect supervisor session", []); + return; + } + const escaped = inspected.peers.filter((peer) => peer.pgrp !== selfPid); + if (escaped.length > 0) { + reportCleanupFailure("same-session process escaped the owned process group", escaped); + return; + } + if (inspected.peers.length === 0) { + finishWithoutPeers(); + return; + } + // The caller is still the exact process-group leader. This is the only + // negative-PGID SIGKILL in the design; it atomically kills the leader and + // every remaining member, so no stale numeric PGID is ever targeted. + process.kill(-selfPid, "SIGKILL"); +} + +function checkCleanup() { + cleanupTimer = null; + const inspected = sessionPeers(); + if (inspected.kind !== "known") { + reportCleanupFailure("cannot inspect supervisor session", []); + return; + } + if (inspected.peers.length === 0) { + finishWithoutPeers(); + return; + } + const escaped = inspected.peers.filter((peer) => peer.pgrp !== selfPid); + if (escaped.length > 0) { + reportCleanupFailure("same-session process escaped the owned process group", escaped); + return; + } + if (Date.now() >= cleanupDeadline) { + forceOwnedGroup(); + return; + } + cleanupTimer = setTimeout(checkCleanup, POLL_MS); +} + +function beginGraceful(signal) { + if (!launched) { + process.exit(0); + return; + } + if (!stopping) { + stopping = true; + cleanupFailureReported = false; + cleanupDeadline = Date.now() + GRACE_MS; + // Setting the stopping flag first prevents the supervisor's own handler + // from recursively rebroadcasting this group-directed signal. + try { process.kill(-selfPid, signal); } + catch (error) { + reportCleanupFailure("failed to signal owned process group: " + String(error), []); + return; + } + } + if (!cleanupTimer) cleanupTimer = setTimeout(checkCleanup, POLL_MS); +} + +function beginForce() { + if (!launched) { + process.exit(0); + return; + } + stopping = true; + cleanupFailureReported = false; + forceOwnedGroup(); +} + +function failBeforeOrAfterLaunch(phase, error) { + send({ v: 1, type: "fatal", phase, message: String(error && error.message || error) }); + if (launched) beginForce(); + else process.exit(125); +} + +process.on("message", (message) => { + if (!message || message.v !== 1 || typeof message.type !== "string") return; + if (message.type === "launch") { + if (launched || stopping) return; + const binary = message.binary; + if (typeof binary !== "string" || !isAbsolute(binary) || binary.includes("\0")) { + failBeforeOrAfterLaunch("launch", new Error("invalid pinned OpenCode binary")); + return; + } + launched = true; + try { + vendor = spawn(binary, ["acp"], { + env: process.env, + stdio: "inherit", + detached: false, + }); + } catch (error) { + failBeforeOrAfterLaunch("spawn", error); + return; + } + vendor.once("spawn", () => { + send({ v: 1, type: "child-started", childPid: vendor.pid }); + }); + vendor.once("error", (error) => { + failBeforeOrAfterLaunch("spawn", error); + }); + vendor.once("exit", (code, signal) => { + vendorExit = { code, signal }; + send({ v: 1, type: "vendor-exit", code, signal }); + beginGraceful("SIGTERM"); + }); + return; + } + if (message.type === "stop") { + if (message.mode === "force") beginForce(); + else beginGraceful(message.signal === "SIGINT" ? "SIGINT" : "SIGTERM"); + } +}); + +process.on("disconnect", () => beginGraceful("SIGTERM")); +process.on("SIGTERM", () => { if (!stopping) beginGraceful("SIGTERM"); }); +process.on("SIGINT", () => { if (!stopping) beginGraceful("SIGINT"); }); +process.on("SIGHUP", () => { if (!stopping) beginGraceful("SIGTERM"); }); +process.on("uncaughtException", (error) => failBeforeOrAfterLaunch("uncaughtException", error)); +process.on("unhandledRejection", (error) => failBeforeOrAfterLaunch("unhandledRejection", error)); + +if (process.platform !== "linux" || typeof process.send !== "function" || !process.connected) { + process.exit(125); +} else { + const stat = readStat(selfPid); + if (!stat || stat.pgrp !== selfPid || stat.session !== selfPid) { + process.exit(125); + } else { + send({ + v: 1, + type: "supervisor-ready", + pid: selfPid, + identity: stat.identity, + processGroupId: stat.pgrp, + sessionId: stat.session, + }, (error) => { + if (error) process.exit(125); + }); + } +} +`; diff --git a/agent-node/src/runtime/opencode-acp/runtime.ts b/agent-node/src/runtime/opencode-acp/runtime.ts index bc969c72..1014bf7d 100644 --- a/agent-node/src/runtime/opencode-acp/runtime.ts +++ b/agent-node/src/runtime/opencode-acp/runtime.ts @@ -36,12 +36,14 @@ import { } from "./client"; import { resolve } from "path"; import { + bindOpencodeChildProcessGroup, buildOpencodeChildEnv, cleanupOpencodeChildEnv, discardUnspawnedOpencodeChildEnv, readOpencodeProcessIdentity, revalidateOpencodeChildLaunch, type OpencodeExitedProcessIdentity, + type OpencodeSpawnedProcessIdentity, } from "./child-env"; import { discoverOpencodeForbiddenRoots, @@ -154,11 +156,14 @@ export async function openOpencodeRuntime(opts: { client.on("error", (error: Error) => { warn(`[opencode-acp] child process error: ${error.message}`); }); + client.on("cleanupError", (error: Error) => { + warn(`[opencode-acp] supervisor cleanup retained for retry: ${error.message}`); + }); let childEnv: NodeJS.ProcessEnv | undefined; let effectiveCwd: string; let launchCleaned = false; let spawnAttempted = false; - let spawnedProcessIdentity: Omit | undefined; + let spawnedProcessIdentity: OpencodeSpawnedProcessIdentity | undefined; const cleanupLaunch = (exitedProcess?: OpencodeExitedProcessIdentity): boolean => { if (launchCleaned) return true; if (!childEnv) return true; @@ -242,16 +247,29 @@ export async function openOpencodeRuntime(opts: { }); client.start({ cwd: effectiveCwd, env: childEnv, binary: spawnedBinary }); spawnAttempted = true; - const childPid = client.processId; - const childIdentity = childPid === undefined + // Expose the owner handle before the first await. At this point the + // supervisor has not launched OpenCode, so parent death can only leave an + // empty anchor which exits on IPC disconnect. + opts.onClient?.(client); + const supervisor = await client.prepare(); + const childPid = supervisor?.pid ?? client.processId; + const childIdentity = supervisor?.identity ?? (childPid === undefined ? undefined - : readOpencodeProcessIdentity(childPid); + : readOpencodeProcessIdentity(childPid)); if (childPid !== undefined) { - spawnedProcessIdentity = { pid: childPid, identity: childIdentity ?? null }; + spawnedProcessIdentity = { + pid: childPid, + identity: childIdentity ?? null, + processGroupId: supervisor?.processGroupId ?? client.processGroupId ?? null, + sessionId: supervisor?.sessionId ?? client.sessionId ?? null, + }; + if (spawnedProcessIdentity.processGroupId !== null) { + bindOpencodeChildProcessGroup(workDir, childEnv, spawnedProcessIdentity); + } } - // Expose the live handle before the first await so SIGTERM during a slow - // initialize/session handshake can still kill the child. - opts.onClient?.(client); + // Marker file + parent directory were fsync'd by the binding call. Only + // now may the supervisor inherit fd0/1/2 into the vendor process. + await client.activate(); // Handshake: initialize (declare client capabilities). await client.request("initialize", { @@ -298,10 +316,12 @@ export async function openOpencodeRuntime(opts: { } catch (error) { // initialize/session failures happen before a runtime session is returned; // without this boundary the ACP child survives with no owner. - if (client.isRunning) { - await client.stop("SIGKILL").catch((stopError: any) => { - warn(`[opencode-acp] failed to kill child after open failure: ${stopError?.message ?? stopError}`); - }); + let cleanupError: unknown; + try { + await client.stop("SIGKILL", 5_000); + } catch (stopError: any) { + cleanupError = stopError; + warn(`[opencode-acp] failed to clean child/group after open failure: ${stopError?.message ?? stopError}`); } // Covers binary-probe/start failures where no child existed to emit exit. if (!spawnAttempted && childEnv) { @@ -309,6 +329,12 @@ export async function openOpencodeRuntime(opts: { } else if (childEnv) { cleanupLaunch(); } + if (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "OpenCode runtime open failed and supervisor cleanup is unconfirmed", + ); + } throw error; } } @@ -334,13 +360,15 @@ export async function opencodeThink( // poisoned process boundary: kill it before returning control. agent-node's // onExit hook clears the shared handle, so the next task opens a fresh child. const killFailedTurnChild = async (phase: string): Promise => { - if (!runtime.client.isRunning) return; - await runtime.client.stop("SIGKILL").catch((stopError: any) => { + try { + await runtime.client.stop("SIGKILL", 5_000); + } catch (stopError: any) { warn( - `[opencode-acp] failed to kill child after ${phase}: ` + + `[opencode-acp] failed to clean child after ${phase}: ` + `${stopError?.message ?? stopError}`, ); - }); + throw stopError; + } }; const onNotification = (n: JsonRpcNotification) => { @@ -355,7 +383,14 @@ export async function opencodeThink( prompt: [{ type: "text", text: opts.prompt }], }, idleTimeoutMs); } catch (error) { - await killFailedTurnChild("session/prompt failure"); + try { + await killFailedTurnChild("session/prompt failure"); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "OpenCode prompt failed and supervisor cleanup is unconfirmed", + ); + } throw error; } finally { runtime.client.off("notification", onNotification); @@ -401,7 +436,14 @@ export async function opencodeThink( rescued = true; } } catch (e: any) { - await killFailedTurnChild("#383 rescue prompt failure"); + try { + await killFailedTurnChild("#383 rescue prompt failure"); + } catch (cleanupError) { + throw new AggregateError( + [e, cleanupError], + "OpenCode rescue prompt failed and supervisor cleanup is unconfirmed", + ); + } warn(`[opencode-acp] #383 rescue re-prompt failed; child discarded: ${e?.message ?? e}`); } finally { runtime.client.off("notification", onRescueNotification); diff --git a/docs/tests/pr1-smoke.txt b/docs/tests/pr1-smoke.txt index efc63138..ab8c7823 100644 --- a/docs/tests/pr1-smoke.txt +++ b/docs/tests/pr1-smoke.txt @@ -1,6 +1,6 @@ # RFC-029 PR① smoke gate -date: 2026-07-16T02:57:19+00:00 +date: 2026-07-16T07:27:08+00:00 bun: 1.3.1 node: v18.20.4 @@ -12,7 +12,7 @@ node: v18.20.4 30 pass 0 fail 38 expect() calls -Ran 30 tests across 1 file. [57.00ms] +Ran 30 tests across 1 file. [59.00ms] ``` ## S2 — wizard picker source has opencode-cli @@ -27,8 +27,8 @@ Ran 30 tests across 1 file. [57.00ms] --- [anet] Starting new session for "testnode" [opencode-cli]... -[anet] opencode-cli requires the exact paired global @sleep2agi/agent-node@2.5.0-preview.26; automatic npx execution is disabled. -[anet] Install exact pair: npm install -g @sleep2agi/agent-network@2.3.0-preview.34 @sleep2agi/agent-node@2.5.0-preview.26 +[anet] opencode-cli requires the exact paired global @sleep2agi/agent-node@2.5.0-preview.27; automatic npx execution is disabled. +[anet] Install exact pair: npm install -g @sleep2agi/agent-network@2.3.0-preview.35 @sleep2agi/agent-node@2.5.0-preview.27 [anet] Incompatible opencode-ai runtime. [anet] Expected trusted opencode-ai@1.18.1; opencode package identity/version check failed: no trusted exact opencode-ai package entrypoint found on PATH → Install/reinstall exact: npm install -g opencode-ai@1.18.1 @@ -47,18 +47,18 @@ exit=1 ``` [agent-node] Config: /tmp/anethome-s4/.anet/nodes/testnode/config.json (source=primary) -[02:57:47] [INFO ] [testnode] 启动 -[02:57:47] [INFO ] [testnode] alias: testnode [from: --alias flag] -[02:57:47] [INFO ] [testnode] runtime: opencode-cli -[02:57:47] [INFO ] [testnode] model: opencode/deepseek-v4-flash-free -[02:57:47] [INFO ] [testnode] hub: http://127.0.0.1:9999 (auth) -[02:57:47] [WARN ] [testnode] token 验证失败 — 检查 token 是否有效。运行: anet login -[02:57:47] [INFO ] [testnode] tools: all (Claude Code preset — built-in: WebFetch/WebSearch/Bash/Read/Write/Edit/Glob/Grep/Task/...) -[02:57:47] [INFO ] [testnode] channels: (none) -[02:57:47] [INFO ] [testnode] session: (new) -[02:57:47] [INFO ] [testnode] log-dir: /repo/agent-node/.anet/nodes/testnode/logs -[02:57:47] [INFO ] [testnode] goals: /tmp/anethome-s4/.anet/nodes/testnode/goals.json -[02:57:47] [INFO ] [testnode] goals scheduler: enabled (runtime=opencode) +[07:27:34] [INFO ] [testnode] 启动 +[07:27:34] [INFO ] [testnode] alias: testnode [from: --alias flag] +[07:27:34] [INFO ] [testnode] runtime: opencode-cli +[07:27:34] [INFO ] [testnode] model: opencode/deepseek-v4-flash-free +[07:27:34] [INFO ] [testnode] hub: http://127.0.0.1:9999 (auth) +[07:27:34] [WARN ] [testnode] token 验证失败 — 检查 token 是否有效。运行: anet login +[07:27:34] [INFO ] [testnode] tools: all (Claude Code preset — built-in: WebFetch/WebSearch/Bash/Read/Write/Edit/Glob/Grep/Task/...) +[07:27:34] [INFO ] [testnode] channels: (none) +[07:27:34] [INFO ] [testnode] session: (new) +[07:27:34] [INFO ] [testnode] log-dir: /repo/agent-node/.anet/nodes/testnode/logs +[07:27:34] [INFO ] [testnode] goals: /tmp/anethome-s4/.anet/nodes/testnode/goals.json +[07:27:34] [INFO ] [testnode] goals scheduler: enabled (runtime=opencode) error: Unable to connect. Is the computer able to access the url? path: "http://127.0.0.1:9999/mcp", errno: 0, @@ -70,13 +70,13 @@ Bun v1.3.1 (Linux x64 baseline) ✓ agent-node reached the opencode-cli runtime path -## S5 — unknown configured runtime fails closed +## S5 — unrelated runtime bypasses absent binding under HOME/.anet=0775, then fails closed ``` [anet] Refusing to start node "bogusnode": unsupported runtime "future-runtime-typo"; expected one of: claude-agent-sdk, claude-code-cli, codex-sdk, codex-app-server, grok-build-acp, opencode-cli exit=1 ``` - ✓ unknown runtime rejected before launch + ✓ absent binding did not reject HOME/.anet=0775; unknown runtime rejected before launch OVERALL: PASS diff --git a/docs/tests/pr2-mock-acp.txt b/docs/tests/pr2-mock-acp.txt index 5ed19b90..62a4fcc8 100644 --- a/docs/tests/pr2-mock-acp.txt +++ b/docs/tests/pr2-mock-acp.txt @@ -1,6 +1,6 @@ # RFC-029 PR② — mock-opencode ACP CI gate -date: 2026-07-16T02:59:31+00:00 +date: 2026-07-16T07:28:18+00:00 bun: 1.3.1 node: v18.20.4 diff --git a/docs/tests/pr3-smoke.txt b/docs/tests/pr3-smoke.txt index 16c35fa9..4fb54ec5 100644 --- a/docs/tests/pr3-smoke.txt +++ b/docs/tests/pr3-smoke.txt @@ -1,6 +1,6 @@ # RFC-029 PR③ — preset + upgrade-pin smoke -date: 2026-07-16T03:05:30+00:00 +date: 2026-07-16T07:31:30+00:00 bun: 1.3.1 node: v18.20.4 @@ -10,8 +10,8 @@ node: v18.20.4 27 pass 0 fail - 129 expect() calls -Ran 27 tests across 2 files. [335.00ms] + 134 expect() calls +Ran 27 tests across 2 files. [190.00ms] ``` ## S2 — auth.json / opencode.json materialization diff --git a/docs/tests/report-test226.txt b/docs/tests/report-test226.txt index a2e8ffaa..447e661a 100644 --- a/docs/tests/report-test226.txt +++ b/docs/tests/report-test226.txt @@ -1,6 +1,6 @@ # Test 226 — opencode-cli release pin matrix -date: 2026-07-16T03:15:29+00:00 +date: 2026-07-16T07:37:51+00:00 candidate pin: 1.18.1 opencode: 1.18.1 bun: 1.3.1 @@ -15,7 +15,7 @@ node: v18.20.4 # RFC-029 PR④ — kernel-live ACP e2e report -date: 2026-07-16T03:15:30+00:00 +date: 2026-07-16T07:37:53+00:00 bun: 1.3.1 node: v18.20.4 opencode: 1.18.1 @@ -24,32 +24,32 @@ free model: opencode/deepseek-v4-flash-free ## Full harness output ``` -[runtime.log] [opencode-acp] session/new — ses_09713c95... +[runtime.log] [opencode-acp] session/new — ses_09623901... ===S-happy-live-BEGIN=== { "freeModel": "opencode/deepseek-v4-flash-free", - "wallMs": 9227, + "wallMs": 10221, "replyText": "hello world", "replyTextLength": 11, "thoughtTextLength": 94, - "sessionId": "ses_09713c955ffeS3V1KKYzyigDOR", + "sessionId": "ses_09623901affe7BUGCKxkw3l7Zr", "chunks": 2, "thoughtChunks": 22, "stopReason": "end_turn", "rescued": false, "usage": { - "inputTokens": 3657, + "inputTokens": 2020, "outputTokens": 3, - "totalTokens": 3682, + "totalTokens": 2045, "thoughtTokens": 22 }, "pidsBefore": [], "pidsDuring": [ - 83 + 93 ], "pidsAfter": [], "logsFromRuntime": [ - "[opencode-acp] session/new — ses_09713c95..." + "[opencode-acp] session/new — ses_09623901..." ], "warnsFromRuntime": [] } @@ -61,13 +61,13 @@ harness exit=0 ## Assertions ✓ opencode child present during turn (pgrep) — ge '1' got '1' - ✓ session id issued by session/new — regex '^ses_' got 'ses_09713c955ffeS3V1KKYzyigDOR' + ✓ session id issued by session/new — regex '^ses_' got 'ses_09623901affe7BUGCKxkw3l7Zr' ✓ at least one agent_message_chunk — ge '1' got '2' ✓ replyText non-empty (real upstream free model produced text) — gt '0' got '11' ✓ replyText looks like a real turn (contains a letter) — regex '[A-Za-z]' got 'hello world' ✓ stopReason recorded — regex '.+' got 'end_turn' ✓ no orphan opencode after runtime.client.stop — eq '0' got '0' - ✓ wall time under 3-minute idle ceiling — regex '^[0-9]+$' got '9227' + ✓ wall time under 3-minute idle ceiling — regex '^[0-9]+$' got '10221' OVERALL: PASS trailer: RFC-029 PR④ kernel-live — PASS diff --git a/docs/tests/report-test384-stop-orphan-l5-transient-root-fail.txt b/docs/tests/report-test384-stop-orphan-l5-transient-root-fail.txt new file mode 100644 index 00000000..b4ec4885 --- /dev/null +++ b/docs/tests/report-test384-stop-orphan-l5-transient-root-fail.txt @@ -0,0 +1,79 @@ +# test384 — opencode local-package preview release gate (failed: opening-window transient root cleanup) + +date: 2026-07-16T07:03:36+00:00 +node: v22.23.1 +bun: 1.3.14 +opencode expected: 1.18.1 +agent-network expected: 2.3.0-preview.35 (publish tag preview) +agent-node expected: 2.5.0-preview.27 (publish tag preview) +free model: opencode/deepseek-v4-flash-free +hub: isolated loopback :9384, db=/tmp/test384/commhub-test384.db +external safe base: /run/user/0/anet-test384-safe (mode 700) + +## L0 — environment + locally packed artifacts +opencode acp + +start ACP (Agent Client Protocol) server + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --port port to listen on [number] [default: 0] + --hostname hostname to listen on [string] [default: "127.0.0.1"] + --mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) + [boolean] [default: false] + --mdns-domain custom domain name for mDNS service (default: opencode.local) + [string] [default: "opencode.local"] + --cors additional domains to allow for CORS [array] [default: []] + --cwd working directory [string] [default: "/test384"]PASS: exact local tarballs installed — agent-network=2.3.0-preview.35 agent-node=2.5.0-preview.27; both publishConfig.tag=preview +PASS: tarball sha256 network=12752c0909cab1720295713def179f72ac4e05b13adbd207d997d628012d0d1d +PASS: tarball sha256 node=5cf33b6a536a264a73d34f123ef2fa8857040b954e48521e8c0c98edc01171e8 +PASS: agent-node bundle markers processWithOpencode + opencode-cli present +PASS: opencode-ai exact pin=1.18.1 and acp smoke passed + +## L1 — isolated CommHub + real CLI login +PASS: secured local hub ready; registration + anet login + default network persisted + +## L1.5 — pre-seeded node-state symlink is rejected before profile secret write +PASS: malicious node-root symlink failed closed; ntok profile/config dotenv did not escape +PASS: ordinary pre-planted dotenv/auth/invalid config were atomically reset on keyless create; login hint uses the sandboxed helper + +## L1.6 — exact 1.18.1 auth-login uses a disposable root and imports only validated API auth +AUTH_LOGIN_PASS: interrupt +AUTH_LOGIN_PASS: success +PASS: traversal ref rejected; interrupted login preserved old auth; successful exact-pin login atomically imported only anthropic API auth +PASS: auth-login temporary roots=0 and planted persistent DB/log/cache/state/runtime/tmp links caused zero outside writes +PASS: real auth prompt/interrupt/success never executed the malicious project-ancestor plugin + +## L2 — real pexpect picker: unnamed/Anthropic + named/OpenAI +PEXPECT_PASS runtime_choices=6 exact_order=yes unnamed=opencode-cli/anthropic named=opencode-cli/openai +PASS: both installed-bundle picker paths rendered the exact 6-choice canonical-main set/order, selected opencode-cli, and exited 0 + +## L3 — preset auth/config materialization and permissions +PASS: Anthropic/OpenAI provider shapes and synthetic dummy-key equality verified (values redacted) +PASS: opencode-ai@1.18.1 auth list consumed both node-scoped preset files +PASS: both auth.json and opencode.json are mode 600 for both nodes +PASS: node and persistent OpenCode state roots are pre-created mode 700 +PASS: preset + upstream effective config disable bash/read/glob/grep/edit/write/list/task/skill/question + +## L3.5 — post-create config/dotenv replacement cannot bypass the OpenCode binding +PASS: post-create config.json symlink was refused; external target stayed byte-identical +PASS: regular runtime downgrade was refused by the external OpenCode binding before NODE_OPTIONS execution +PASS: post-create .env symlink was refused; external target stayed byte-identical and payload did not run + +## L4 — hostile inherited config/XDG + child env allowlist +SECURITY_ENV_PASS forbidden keys absent; external 0700 cwd/HOME/XDG; process PWD and ACP session cwd identical; project/plugin/skill/Claude/LSP discovery disabled +PASS: fake ACP task replied through installed launchers; credential env names were absent +PASS: same-version project-local opencode-ai impersonator was skipped; canonical external package fake ran +PASS: hostile XDG/OPENCODE_CONFIG* lost precedence; discovery controls present; fresh launch root cleaned + +## L5 — rejected handshake and SIGTERM during opening leave no child +PASS: child that rejected initialize and stayed alive was explicitly reaped +transient OpenCode root survived cleanup: /run/user/0/anet-test384-safe/.anet-opencode-launch-2tyW5Y +transient OpenCode root survived cleanup: /run/user/0/anet-test384-safe/.anet-opencode-launch-2tyW5Y + +OVERALL: FAIL at L5 handshake failure + opening-window orphan cleanup (exit=1) +trailer: test384 local package -> picker -> security/lifecycle gates -> real OpenCode reply — FAIL diff --git a/docs/tests/report-test384-stop-orphan-l8-rename-fail.txt b/docs/tests/report-test384-stop-orphan-l8-rename-fail.txt new file mode 100644 index 00000000..ab4485ab --- /dev/null +++ b/docs/tests/report-test384-stop-orphan-l8-rename-fail.txt @@ -0,0 +1,104 @@ +# test384 — opencode local-package preview release gate (failed: prepared rename directory mode) + +date: 2026-07-16T06:44:49+00:00 +node: v22.23.1 +bun: 1.3.14 +opencode expected: 1.18.1 +agent-network expected: 2.3.0-preview.35 (publish tag preview) +agent-node expected: 2.5.0-preview.27 (publish tag preview) +free model: opencode/deepseek-v4-flash-free +hub: isolated loopback :9384, db=/tmp/test384/commhub-test384.db +external safe base: /run/user/0/anet-test384-safe (mode 700) + +## L0 — environment + locally packed artifacts +opencode acp + +start ACP (Agent Client Protocol) server + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --port port to listen on [number] [default: 0] + --hostname hostname to listen on [string] [default: "127.0.0.1"] + --mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) + [boolean] [default: false] + --mdns-domain custom domain name for mDNS service (default: opencode.local) + [string] [default: "opencode.local"] + --cors additional domains to allow for CORS [array] [default: []] + --cwd working directory [string] [default: "/test384"]PASS: exact local tarballs installed — agent-network=2.3.0-preview.35 agent-node=2.5.0-preview.27; both publishConfig.tag=preview +PASS: tarball sha256 network=e72a489022f3d61c273103f882307f0b90d0ed00e9d0e891924e7d2c5298580f +PASS: tarball sha256 node=5cf33b6a536a264a73d34f123ef2fa8857040b954e48521e8c0c98edc01171e8 +PASS: agent-node bundle markers processWithOpencode + opencode-cli present +PASS: opencode-ai exact pin=1.18.1 and acp smoke passed + +## L1 — isolated CommHub + real CLI login +PASS: secured local hub ready; registration + anet login + default network persisted + +## L1.5 — pre-seeded node-state symlink is rejected before profile secret write +PASS: malicious node-root symlink failed closed; ntok profile/config dotenv did not escape +PASS: ordinary pre-planted dotenv/auth/invalid config were atomically reset on keyless create; login hint uses the sandboxed helper + +## L1.6 — exact 1.18.1 auth-login uses a disposable root and imports only validated API auth +AUTH_LOGIN_PASS: interrupt +AUTH_LOGIN_PASS: success +PASS: traversal ref rejected; interrupted login preserved old auth; successful exact-pin login atomically imported only anthropic API auth +PASS: auth-login temporary roots=0 and planted persistent DB/log/cache/state/runtime/tmp links caused zero outside writes +PASS: real auth prompt/interrupt/success never executed the malicious project-ancestor plugin + +## L2 — real pexpect picker: unnamed/Anthropic + named/OpenAI +PEXPECT_PASS runtime_choices=6 exact_order=yes unnamed=opencode-cli/anthropic named=opencode-cli/openai +PASS: both installed-bundle picker paths rendered the exact 6-choice canonical-main set/order, selected opencode-cli, and exited 0 + +## L3 — preset auth/config materialization and permissions +PASS: Anthropic/OpenAI provider shapes and synthetic dummy-key equality verified (values redacted) +PASS: opencode-ai@1.18.1 auth list consumed both node-scoped preset files +PASS: both auth.json and opencode.json are mode 600 for both nodes +PASS: node and persistent OpenCode state roots are pre-created mode 700 +PASS: preset + upstream effective config disable bash/read/glob/grep/edit/write/list/task/skill/question + +## L3.5 — post-create config/dotenv replacement cannot bypass the OpenCode binding +PASS: post-create config.json symlink was refused; external target stayed byte-identical +PASS: regular runtime downgrade was refused by the external OpenCode binding before NODE_OPTIONS execution +PASS: post-create .env symlink was refused; external target stayed byte-identical and payload did not run + +## L4 — hostile inherited config/XDG + child env allowlist +SECURITY_ENV_PASS forbidden keys absent; external 0700 cwd/HOME/XDG; process PWD and ACP session cwd identical; project/plugin/skill/Claude/LSP discovery disabled +PASS: fake ACP task replied through installed launchers; credential env names were absent +PASS: same-version project-local opencode-ai impersonator was skipped; canonical external package fake ran +PASS: hostile XDG/OPENCODE_CONFIG* lost precedence; discovery controls present; fresh launch root cleaned + +## L5 — rejected handshake and SIGTERM during opening leave no child +PASS: child that rejected initialize and stayed alive was explicitly reaped +PASS: SIGTERM during unresolved initialize reaped agent-node + opening OpenCode child + +## L5.5 — external safe-base ancestor candidate refuses before ACP spawn +PASS: candidate in trusted-base ancestor chain hard-failed before OpenCode ACP spawn; transient roots=0 + +## L6 — exact 1.18.1 real task ignores malicious ancestor config/plugin +PASS: installed agent-network launched installed agent-node; alias reached idle +PASS: task 790e9ed2... reached replied under hostile parent overrides; result length=26 +PASS: exact opencode-ai@1.18.1 kept node free model authoritative; ancestor file-plugin marker absent +safe node-log evidence: +[06:45:35] [INFO ] [wizard-openai] SSE connected +[06:45:35] [INFO ] [wizard-openai] → processing [opencode]: Hold the ACP initialize request open until supervisor shutdown. +[06:45:46] [INFO ] [wizard-openai] [opencode-acp] session/new — ses_09653696... +[06:45:49] [INFO ] [wizard-openai] [opencode] turn done | reply=250ch thought=326ch chunks=74 stopReason=end_turn rescued=false in=2016 out=76 thought=64 +[06:45:49] [INFO ] [wizard-openai] sending reply to api (task 8782b6fd, status=replied)... +[06:45:49] [INFO ] [wizard-openai] → processing [opencode]: Reply in one short sentence confirming the preview OpenCode end-to-end test. +[06:45:51] [INFO ] [wizard-openai] [opencode] turn done | reply=10ch thought=120ch chunks=2 stopReason=end_turn rescued=false in=189 out=3 thought=26 +[06:45:52] [INFO ] [wizard-openai] sending reply to api (task 790e9ed2, status=replied)... + +## L7 — graceful shutdown + global orphan audit +PASS: launcher exited on SIGTERM; zero agent-node/opencode-acp orphan processes + +## L8 — external OpenCode binding follows rename/delete lifecycle +PASS: downgraded bound OpenCode profile cannot launder its runtime through rename +[anet] persisted canonical node_id n_64f2c8aa before rename. +[anet] rename PHASE 1 failed: OpenCode preset refuses node workDir at /tmp/test384/project/.anet/nodes/wizard-anthropic-renamed: directory mode must be 0700 — rolling back +[anet] rollback complete — "wizard-anthropic" unchanged. + +OVERALL: FAIL at L8 binding rename/delete lifecycle (exit=1) +trailer: test384 local package -> picker -> security/lifecycle gates -> real OpenCode reply — FAIL diff --git a/docs/tests/report-test384.txt b/docs/tests/report-test384.txt index 86b71e0d..b02dfca4 100644 --- a/docs/tests/report-test384.txt +++ b/docs/tests/report-test384.txt @@ -1,11 +1,11 @@ # test384 — opencode local-package preview release gate -date: 2026-07-16T03:33:28+00:00 +date: 2026-07-16T07:21:58+00:00 node: v22.23.1 bun: 1.3.14 opencode expected: 1.18.1 -agent-network expected: 2.3.0-preview.34 (publish tag preview) -agent-node expected: 2.5.0-preview.26 (publish tag preview) +agent-network expected: 2.3.0-preview.35 (publish tag preview) +agent-node expected: 2.5.0-preview.27 (publish tag preview) free model: opencode/deepseek-v4-flash-free hub: isolated loopback :9384, db=/tmp/test384/commhub-test384.db external safe base: /run/user/0/anet-test384-safe (mode 700) @@ -28,9 +28,9 @@ Options: --mdns-domain custom domain name for mDNS service (default: opencode.local) [string] [default: "opencode.local"] --cors additional domains to allow for CORS [array] [default: []] - --cwd working directory [string] [default: "/test384"]PASS: exact local tarballs installed — agent-network=2.3.0-preview.34 agent-node=2.5.0-preview.26; both publishConfig.tag=preview -PASS: tarball sha256 network=f6b915577205b38eefd4e1d814892684d0c53185947dac1c9f59e3c86688afcb -PASS: tarball sha256 node=7edd95ac0835a6d0fac649a03448d0bbec203ca08cf4fa8fc7584b051e8ec72f + --cwd working directory [string] [default: "/test384"]PASS: exact local tarballs installed — agent-network=2.3.0-preview.35 agent-node=2.5.0-preview.27; both publishConfig.tag=preview +PASS: tarball sha256 network=01107b1f52d8e76f25f2e0be7ac4923acd65ac38efffa582e925c98ee2048a10 +PASS: tarball sha256 node=31088718637d8db37ddb326d9757671b01bfc81bf0f90bdef6134a8b6fb1c9a6 PASS: agent-node bundle markers processWithOpencode + opencode-cli present PASS: opencode-ai exact pin=1.18.1 and acp smoke passed @@ -79,20 +79,31 @@ PASS: candidate in trusted-base ancestor chain hard-failed before OpenCode ACP s ## L6 — exact 1.18.1 real task ignores malicious ancestor config/plugin PASS: installed agent-network launched installed agent-node; alias reached idle -PASS: task 658f2d0f... reached replied under hostile parent overrides; result length=75 +PASS: task da867636... reached replied under hostile parent overrides; result length=117 PASS: exact opencode-ai@1.18.1 kept node free model authoritative; ancestor file-plugin marker absent safe node-log evidence: -[03:34:11] [INFO ] [wizard-openai] SSE connected -[03:34:12] [INFO ] [wizard-openai] → processing [opencode]: Hold the ACP initialize request open until supervisor shutdown. -[03:34:18] [INFO ] [wizard-openai] [opencode-acp] session/new — ses_09702b38... -[03:34:27] [INFO ] [wizard-openai] [opencode] turn done | reply=580ch thought=1282ch chunks=126 stopReason=end_turn rescued=false in=520 out=127 thought=253 -[03:34:27] [INFO ] [wizard-openai] sending reply to api (task 2021be48, status=replied)... -[03:34:28] [INFO ] [wizard-openai] → processing [opencode]: Reply in one short sentence confirming the preview OpenCode end-to-end test. -[03:34:29] [INFO ] [wizard-openai] [opencode] turn done | reply=59ch thought=208ch chunks=13 stopReason=end_turn rescued=false in=23 out=14 thought=42 -[03:34:29] [INFO ] [wizard-openai] sending reply to api (task 658f2d0f, status=replied)... +[07:22:43] [INFO ] [wizard-openai] SSE connected +[07:22:43] [INFO ] [wizard-openai] → processing [opencode]: Hold the ACP initialize request open until supervisor shutdown. +[07:22:51] [INFO ] [wizard-openai] [opencode-acp] session/new — ses_09631756... +[07:22:54] [INFO ] [wizard-openai] [opencode] turn done | reply=132ch thought=355ch chunks=62 stopReason=end_turn rescued=false in=2016 out=64 thought=70 +[07:22:54] [INFO ] [wizard-openai] sending reply to api (task 31027a45, status=replied)... +[07:22:54] [INFO ] [wizard-openai] → processing [opencode]: Reply in one short sentence confirming the preview OpenCode end-to-end test. +[07:22:58] [INFO ] [wizard-openai] [opencode] turn done | reply=101ch thought=1174ch chunks=22 stopReason=end_turn rescued=false in=177 out=24 thought=254 +[07:22:58] [INFO ] [wizard-openai] sending reply to api (task da867636, status=replied)... ## L7 — graceful shutdown + global orphan audit PASS: launcher exited on SIGTERM; zero agent-node/opencode-acp orphan processes +## L8 — external OpenCode binding follows rename/delete lifecycle +PASS: downgraded bound OpenCode profile cannot launder its runtime through rename +PASS: missing OpenCode binding cannot be laundered into a new binding through rename +[anet] persisted canonical node_id n_5ace4396 before rename. +[anet] note: "wizard-anthropic" has no server registration yet (never started). Performing local-only rename — no commhub 2PC needed. +[anet] node_id: n_5ace4396 — unchanged (only the alias changed; ntok_ token still valid). +[anet] ✅ Renamed "wizard-anthropic" → "wizard-anthropic-renamed" (txn null). Node was not running — next `anet node start 'wizard-anthropic-renamed'` registers under the new alias. +PASS: local-only OpenCode rename replaced the external binding without a stale old-name record +PASS: bound OpenCode delete refuses before config or binding mutation +PASS: unrelated unbound runtime bypassed populated binding namespace and reached strict runtime validation + OVERALL: PASS trailer: test384 local package -> picker -> security/lifecycle gates -> real OpenCode reply — PASS diff --git a/docs/tests/report-test386-stop-orphan-batch-exit-fail.txt b/docs/tests/report-test386-stop-orphan-batch-exit-fail.txt new file mode 100644 index 00000000..7e26c247 --- /dev/null +++ b/docs/tests/report-test386-stop-orphan-batch-exit-fail.txt @@ -0,0 +1,273 @@ +# Test 386 — opencode-cli stale agent-node launch gate (failed: batch refusal exit status) + +- date: 2026-07-16T06:35:00+00:00 +bun test v1.3.1 (89fa0f34) + +src/opencode-wrapper-stop.test.ts: +(pass) bound OpenCode wrapper TERM-only stop > a responsive wrapper exits after SIGTERM [19.00ms] +(pass) bound OpenCode wrapper TERM-only stop > a parent-forwarded SIGINT retains a SIGSTOP'd wrapper and detached child [170.01ms] + +src/opencode-owner-mode.test.ts: +(pass) OpenCode owner/mode policy > accepts umask-0002 modes only for a non-root uid=gid layout [2.00ms] +(pass) OpenCode owner/mode policy > always rejects world write and keeps root/foreign ownership strict + +src/opencode-agent-node-pair.test.ts: +(pass) OpenCode agent-node release pairing > pins the exact versions being released together +(pass) OpenCode agent-node release pairing > rejects latest 2.4.x-style help and accepts the RFC-029 capability +(pass) OpenCode agent-node release pairing > admits only the exact preview package identity with safe file modes [7.00ms] +(pass) OpenCode agent-node release pairing > skips an exact project-local impersonator and selects the later global package [7.00ms] + +src/opencode-launch-env.test.ts: +(pass) hardenOpencodeAgentNodeEnv > restores launcher PATH and strips every pre-entrypoint loader hook [2.00ms] +(pass) hardenOpencodeAgentNodeEnv > does not mutate the caller's env object [1.00ms] +(pass) hardenOpencodeAgentNodeEnv > strips case-variant loader and PATH keys for Windows semantics + +src/opencode-auth-login.test.ts: +(pass) OpenCode manual auth-login sandbox > builds deterministic provider-specific API-key login argv [1.00ms] +(pass) OpenCode manual auth-login sandbox > uses a fresh all-XDG tree and strips ambient credentials/config hooks [32.00ms] +(pass) OpenCode manual auth-login sandbox > strictly consumes only the selected provider API record through a private leaf [21.00ms] +(pass) OpenCode manual auth-login sandbox > refuses OAuth, mixed-provider and symlink auth shapes without disclosing secrets [23.00ms] +(pass) OpenCode manual auth-login sandbox > persistent planted DB/log links are never exposed and cleanup never follows descendant links [19.00ms] +(pass) OpenCode manual auth-login sandbox > cleanup unlinks a swapped root symlink but never removes its outside target [18.00ms] +(pass) OpenCode manual auth-login sandbox > cleanup quarantines the tracked inode but leaves a regular root-name replacement untouched [16.00ms] +(pass) OpenCode manual auth-login sandbox > a live tracked root whose literal name ends in deleted is still removed [17.00ms] +(pass) OpenCode manual auth-login sandbox > Linux reports nlink zero for a removed directory retained by fd [10.00ms] +(pass) OpenCode manual auth-login sandbox > cleanup retains inode ownership after bounded failure and succeeds on retry [18.00ms] +(pass) OpenCode manual auth-login sandbox > refuses a concurrent live owner marker [19.00ms] +(pass) OpenCode manual auth-login sandbox > refuses a provider that does not match the node's unique configured preset [9.00ms] +(pass) OpenCode manual auth-login sandbox > prunes a dead owner's stale root without following its planted links [28.00ms] +(pass) OpenCode manual auth-login sandbox > PID reuse does not retain a stale credential root [33.00ms] +(pass) OpenCode manual auth-login sandbox > stale sweep resumes a crash-left quarantine while its owner marker remains [36.00ms] +(pass) OpenCode manual auth-login sandbox > stale sweep removes an empty quarantine left after marker-last deletion [18.00ms] +(pass) OpenCode manual auth-login sandbox > spawn-time revalidation rejects a hostile ancestor discovery candidate [19.00ms] +(pass) OpenCode manual auth-login sandbox > with helper always cleans the fresh root when the action throws [19.00ms] + +src/opencode-runtime-binding.test.ts: +(pass) external OpenCode runtime binding > survives regular config runtime downgrade and proves the original exact runtime [10.00ms] +(pass) external OpenCode runtime binding > read returns undefined only for absent state and deterministic keys separate nodes [10.00ms] +(pass) external OpenCode runtime binding > an absent exact leaf does not impose POSIX modes on ordinary runtime state [1.00ms] +(pass) external OpenCode runtime binding > unbound legacy symlink or junction-style node paths remain invisible [6.00ms] +(pass) external OpenCode runtime binding > Windows synthetic permission bits do not disable structural security checks +(pass) external OpenCode runtime binding > secure removal is idempotent and removes the exact binding [9.00ms] +(pass) external OpenCode runtime binding > secure removal refuses tampered content without unlinking it [5.00ms] +(pass) external OpenCode runtime binding > rejects binding-directory and leaf symlinks [7.00ms] +(pass) external OpenCode runtime binding > rejects dangling binding-root and exact-leaf symlinks [3.00ms] +(pass) external OpenCode runtime binding > rejects permissive modes, hard links, and foreign ownership [9.00ms] +(pass) external OpenCode runtime binding > rejects private but tampered runtime, identity, and extra fields [7.00ms] +(pass) external OpenCode runtime binding > rejects binding roots that overlap the canonical project in either direction [3.00ms] +(pass) external OpenCode runtime binding > a symlinked node workDir cannot remove another project's binding [12.00ms] +(pass) assertOpencodeNodeStateUntracked > allows ordinary non-Git projects [1.00ms] +(pass) assertOpencodeNodeStateUntracked > allows ignored/untracked state but rejects git add -f tracked state [32.00ms] +(pass) assertOpencodeNodeStateUntracked > rejects a force-added dotenv or any tracked file below the node directory [34.00ms] + +src/opencode-preset.test.ts: +(pass) OPENCODE_PRESETS registry > exports the two blessed presets (anthropic + openai) +(pass) OPENCODE_PRESETS registry > findOpencodePreset('anthropic') returns the record; unknown returns null [1.00ms] +(pass) readPresetKeyFromEnv — env-only, no interactive prompt > returns the trimmed key when the env var is set +(pass) readPresetKeyFromEnv — env-only, no interactive prompt > returns null when the env var is missing / empty +(pass) buildAuthJsonBody + writeOpencodeAuthJson > body shape matches opencode auth.json convention +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writes to /.local/share/opencode/auth.json with mode 0o600 [7.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writeOpencodeConfigJson lands under .config/opencode with 0o600 [9.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > keyless create atomically clears a private pre-planted auth file [9.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > default tool policy disables filesystem, shell, task, and skill tools [1.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writes only blessed provider identity and strips all pre-planted routing/executable config [8.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > atomically replaces a private but invalid pre-planted config without parsing it [6.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > rejects symlink escapes in workDir, config/data ancestors, and final targets [9.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > validates the full tree before mutation so a bad auth side cannot partially rewrite config [1.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > rejects permissive modes and foreign owners without chmod-follow repair [4.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > prepares .anet/nodes/node before profile secrets and provides atomic private leaf I/O [26.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > accepts an ordinary 0775 project root for a non-root uid=gid private group [1.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > profile preflight rejects .anet/nodes/node and config/.env symlink chains before secret writes [11.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > profile preflight rejects writable ancestors and non-private node roots [2.00ms] + +src/opencode-smoke-env.test.ts: +(pass) buildOpencodeSmokeEnv > locks the exact hardened ancestor candidate set [1.00ms] +(pass) buildOpencodeSmokeEnv > rejects sticky world-writable /tmp instead of silently degrading +(pass) buildOpencodeSmokeEnv > inherits only transport/locale trust settings and controls all OpenCode roots [1.00ms] +(pass) buildOpencodeSmokeEnv > every writable root can be precreated private, including XDG_RUNTIME_DIR [1.00ms] + +src/opencode-package-binary.test.ts: +(pass) validateOpencodePackageBinary > accepts only the canonical exact npm package entrypoint [2.00ms] +(pass) validateOpencodePackageBinary > rejects a same-version package impersonator inside the project [1.00ms] +(pass) validateOpencodePackageBinary > skips a same-version project shim and selects a later trusted package [5.00ms] +(pass) validateOpencodePackageBinary > rejects a monorepo-root package when invoked from a nested app [3.00ms] +(pass) validateOpencodePackageBinary > ordinary 0664 checkout package.json does not abort boundary discovery [1.00ms] +(pass) validateOpencodePackageBinary > accepts both exact registry spellings of bin.opencode [2.00ms] +(pass) validateOpencodePackageBinary > rejects forged name, version, and bin metadata [4.00ms] +(pass) validateOpencodePackageBinary > rejects world-writable files and package ancestors [4.00ms] +(pass) validateOpencodePackageBinary > rejects a symlinked package.json even when its contents are exact [1.00ms] + + 76 pass + 0 fail + 400 expect() calls +Ran 76 tests across 9 files. [990.00ms] +PASS: exact pairing plus launch, TERM-only wrapper stop, auth-login, smoke-env, and preset security unit tests +bun test v1.3.1 (89fa0f34) + +src/runtime/opencode-acp/events.test.ts: +(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_message_chunk with text content → replyText += content.text [1.00ms] +(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_thought_chunk with text → thoughtText, NOT replyText (grok discipline) +(pass) reduceOpencodeAcpNotification — session/update dispatch > tool_call and tool_call_update both bump toolCalls +(pass) reduceOpencodeAcpNotification — session/update dispatch > usage_update snaps totalTokens into state.usage +(pass) reduceOpencodeAcpNotification — session/update dispatch > available_commands_update consumed silently (session-init only) +(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_message_chunk without text content adds a warning +(pass) reduceOpencodeAcpNotification — session/update dispatch > unknown method returns ignored without mutating state +(pass) reduceOpencodeAcpNotification — session/update dispatch > unknown sessionUpdate subtype returns ignored (forward-compat) +(pass) reduceOpencodeAcpResponse — session/prompt terminal response > captures stopReason + usage from result [1.00ms] +(pass) reduceOpencodeAcpResponse — session/prompt terminal response > missing stopReason still marks turn complete +(pass) reduceOpencodeAcpFrames — replay the Phase 0b captured turn > full one-word turn: 10 thought chunks + 1 message chunk + usage + response [1.00ms] +(pass) reduceOpencodeAcpFrames — replay the Phase 0b captured turn > thinking-only terminal turn (no agent_message_chunk) — replyText stays empty + +src/runtime/opencode-acp/child-env.test.ts: +(pass) buildOpencodeChildEnv — deny-by-default boundary > locks the exact hardened ancestor candidate set +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects sticky world-writable /tmp instead of silently degrading [7.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > passes only runtime/network allowlist and controls all state roots [28.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > safe inline policy disables every local tool without replacing provider/model [18.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > unsafe opt-in explicitly overrides the wizard's persisted safe policy [11.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > detects exact managed config sources across Linux, Windows, and macOS [1.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > safe runtime renders ordinary same-uid config through a strict allowlist [30.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > copies only blessed API auth fields into fresh data and keeps persistent state outside the child [23.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > never exposes planted persistent DB/log/cache/state/tmp descendants in safe or unsafe mode [36.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > removes a partially built launch tree when env construction fails [17.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > pre-spawn revalidation hard-fails when an ancestor discovery candidate appears [26.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > keeps active roots but reclaims a dead-owner crash root without following symlinks [54.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > reclaims dead-owner roots after the node workDir is deleted or recreated [67.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > a transient cleanup pathname swap is retried after child exit [29.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > a dead owner marker is retained while an orphan child still references the root [60.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > durable process-group binding protects opaque descendants across later and crash-style sweeps [146.01ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > an exact exited-process identity exemption never hides a live descendant or PID mismatch [85.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects symlinks at workDir and every security-sensitive state layer [20.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects permissive modes and foreign ownership without repairing them [2.00ms] + +src/runtime/opencode-acp/profile-state.test.ts: +(pass) OpenCode private profile state > loads, atomically updates, backs up, and writes a session [20.00ms] +(pass) OpenCode private profile state > a post-load config symlink cannot redirect session writeback [1.00ms] +(pass) OpenCode private profile state > boot refuses a config symlink before self-heal can write its target [1.00ms] +(pass) OpenCode private profile state > backup refuses a pre-planted .prev symlink [1.00ms] +(pass) OpenCode private profile state > runtime hint rejects suspicious config leaves for every runtime [2.00ms] + +src/runtime/opencode-acp/client.test.ts: +(pass) OpencodeAcpClient — request/response correlation > request() resolves with the matching response's result [176.01ms] +(pass) OpencodeAcpClient — request/response correlation > error response rejects the promise with a shaped message [235.01ms] +(pass) OpencodeAcpClient — streaming notifications > emits 'notification' for every session/update frame [184.01ms] +(pass) OpencodeAcpClient — streaming notifications > id-carrying reverse requests get an explicit method-not-found response [180.01ms] +(pass) OpencodeAcpClient — process lifecycle > supervisor receipt is ready before the vendor can inherit launch state [139.01ms] +(pass) OpencodeAcpClient — process lifecycle > child exit rejects all pending requests [145.01ms] +(pass) OpencodeAcpClient — process lifecycle > isRunning flips false after stop() [79.00ms] +(pass) OpencodeAcpClient — process lifecycle > a crashing group leader cannot leave a live descendant behind [194.01ms] +(pass) OpencodeAcpClient — process lifecycle > SIGSTOP supervisor makes stop fail closed until the exact owner resumes [341.01ms] +(pass) OpencodeAcpClient — process lifecycle > external supervisor SIGKILL never triggers a stale PGID kill or clean exit [1645.07ms] +(pass) OpencodeAcpClient — process lifecycle > concurrent stop callers share one verified public exit [57.00ms] +(pass) OpencodeAcpClient — process lifecycle > explicit child env is not merged with the client's process.env [147.01ms] + +src/runtime/opencode-acp/runtime.test.ts: +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — cwd and tool policy > safe default keeps spawn + ACP session in one external launch workspace [214.01ms] +[opencode-acp] session/new — ses_probe_au... +(pass) openOpencodeRuntime — cwd and tool policy > version probe root is credential-free and gone before runtime auth is materialized [265.01ms] +[opencode-acp] session/load ok — resumed ses_existing... +(pass) openOpencodeRuntime — cwd and tool policy > safe session/load reuses the exact spawn PWD as its ACP cwd [231.01ms] +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — cwd and tool policy > explicit unsafe flag restores project cwd and emits a trusted-task warning [223.01ms] +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — opening lifecycle > normal stop removes the launch root and copied vendor auth [255.01ms] +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — opening lifecycle > repeated open/stop cycles do not accumulate launch roots [5899.25ms] +(pass) openOpencodeRuntime — opening lifecycle > an ancestor candidate planted by the version probe hard-fails before ACP spawn [108.00ms] +(pass) openOpencodeRuntime — opening lifecycle > package replacement after credential-free probe is rejected and runtime auth root is discarded [87.00ms] +(pass) openOpencodeRuntime — opening lifecycle > in-place binary self-modification after probe is rejected before credential spawn [88.00ms] +(pass) openOpencodeRuntime — opening lifecycle > production rejects canonical same-version packages below project cwd or node workDir [32.00ms] +(pass) openOpencodeRuntime — opening lifecycle > initialize failure force-kills the child before rejecting [234.01ms] +(pass) openOpencodeRuntime — opening lifecycle > onClient exposes a stalled-handshake child synchronously for shutdown [114.00ms] +[opencode-acp] session/new — ses_idle... +(pass) opencodeThink — failed-turn lifecycle > prompt idle timeout force-kills the child before rejecting [272.01ms] +[opencode-acp] session/new — ses_rescue_i... +[opencode-acp] #383 thinking-only terminal turn (chunks=0 thoughtChunks=1) — re-prompting for plain-text final +(pass) opencodeThink — failed-turn lifecycle > a failed thinking-only rescue discards the child before returning [251.01ms] + +src/runtime/opencode-acp/binary.test.ts: +(pass) resolvePinnedOpencodeBinary > locks the non-root uid=gid umask-0002 compatibility policy +(pass) resolvePinnedOpencodeBinary > accepts the canonical package entrypoint and probes it from the external cwd [42.00ms] +(pass) resolvePinnedOpencodeBinary > accepts an npm-style PATH shim but returns the canonical package binary [32.00ms] +(pass) resolvePinnedOpencodeBinary > rejects a same-version fake package inside the project before executing it [1.00ms] +(pass) resolvePinnedOpencodeBinary > rejects forged package metadata and noncanonical entrypoints [4.00ms] +(pass) resolvePinnedOpencodeBinary > rejects unsafe file, package-directory, ancestor, and owner modes [3.00ms] +(pass) resolvePinnedOpencodeBinary > still enforces exact --version output after package identity succeeds [31.00ms] +(pass) resolvePinnedOpencodeBinary > refuses a caller-selected version other than the vetted release pin [1.00ms] +(pass) resolvePinnedOpencodeBinary > rejects a same-version package in a monorepo ancestor before probing it [1.00ms] +(pass) resolvePinnedOpencodeBinary > discovers a workspace ancestor when the configured project leaf is absent [1.00ms] +(pass) resolvePinnedOpencodeBinary > launcher absolute path wins over a hostile search PATH [29.00ms] +(pass) resolvePinnedOpencodeBinary > rejects non-absolute overrides + + 74 pass + 0 fail + 722 expect() calls +Ran 74 tests across 6 files. [12.77s] +PASS: agent-node OpenCode package, child-env, profile-state, ACP client/events, and runtime unit tests +Error: opencode safe mode refuses OS-managed config source /etc/opencode/opencode.json; managed MCP/tools load after inline config +PASS: safe runtime rejects exact /etc/opencode managed source before hostile MCP/version spawn +$ tsc --noEmit +PASS: agent-network typecheck +$ bun build src/client.ts --outdir dist/src --target node --minify && bun build bin/cli.ts --outdir dist/bin --target node --minify --external @sleep2agi/commhub-server --external bun:sqlite --external '../../server/*' && bun build src/node-server.ts --outdir dist/src --target node --minify --external @modelcontextprotocol/sdk && bun build src/im/feishu/worker.ts --outdir dist/src/im/feishu --target node --minify --external @larksuiteoapi/node-sdk && tsc --emitDeclarationOnly --declaration --outDir dist && bun x javascript-obfuscator dist/bin/cli.js --output dist/bin/cli.js --compact true --string-array true --string-array-encoding base64 && bun x javascript-obfuscator dist/src/client.js --output dist/src/client.js --compact true --string-array true && bun x javascript-obfuscator dist/src/node-server.js --output dist/src/node-server.js --compact true --string-array true +Bundled 1 module in 7ms + + client.js 3.67 KB (entry point) + +Bundled 94 modules in 58ms + + cli.js 0.66 MB (entry point) + +Bundled 2 modules in 9ms + + node-server.js 11.15 KB (entry point) + +Bundled 597 modules in 145ms + + worker.js 2.29 MB (entry point) + + +[javascript-obfuscator-cli] Obfuscating file: dist/bin/cli.js... + +[javascript-obfuscator-cli] Obfuscating file: dist/src/client.js... + +[javascript-obfuscator-cli] Obfuscating file: dist/src/node-server.js... +PASS: agent-network release bundle build +[anet] Stopped "renamed-after-launch" (process killed, server notified) +[anet] bound OpenCode tmux session "renamed-after-launch" was not mutated; inspect it explicitly +PASS: bound stop ignores mutable alias, verifies exact wrapper exit, and retains same-name tmux/binding + +[anet] anet project up — 1 node(s) in /tmp/test386-bound-wedged/work + ⚠ wedged-gate — invalid config: bound opencode-cli nodes require explicit 'anet node start/stop' lifecycle + +────────────────────────────────────────────── + 0/1 up · 1 invalid + Invalid config (not started): + ⚠ wedged-gate — bound opencode-cli nodes require explicit 'anet node start/stop' lifecycle + +[anet] refusing batch stop: 1 bound opencode-cli node(s) require explicit node stop +[anet] refusing delete of bound opencode-cli node without an external lifecycle lock; run 'anet node stop' and retain the node for explicit inspection +FAIL: batch stop did not fail closed for bound OpenCode diff --git a/docs/tests/report-test386-stop-orphan-core-pass.txt b/docs/tests/report-test386-stop-orphan-core-pass.txt new file mode 100644 index 00000000..82323c27 --- /dev/null +++ b/docs/tests/report-test386-stop-orphan-core-pass.txt @@ -0,0 +1,304 @@ +# Test 386 — stop-orphan core pass before lifecycle-port coverage + +- date: 2026-07-16T06:28:15+00:00 +bun test v1.3.1 (89fa0f34) + +src/opencode-wrapper-stop.test.ts: +(pass) bound OpenCode wrapper TERM-only stop > a responsive wrapper exits after SIGTERM [25.00ms] +(pass) bound OpenCode wrapper TERM-only stop > a parent-forwarded SIGINT retains a SIGSTOP'd wrapper and detached child [178.01ms] + +src/opencode-owner-mode.test.ts: +(pass) OpenCode owner/mode policy > accepts umask-0002 modes only for a non-root uid=gid layout [1.00ms] +(pass) OpenCode owner/mode policy > always rejects world write and keeps root/foreign ownership strict + +src/opencode-agent-node-pair.test.ts: +(pass) OpenCode agent-node release pairing > pins the exact versions being released together +(pass) OpenCode agent-node release pairing > rejects latest 2.4.x-style help and accepts the RFC-029 capability +(pass) OpenCode agent-node release pairing > admits only the exact preview package identity with safe file modes [6.00ms] +(pass) OpenCode agent-node release pairing > skips an exact project-local impersonator and selects the later global package [7.00ms] + +src/opencode-launch-env.test.ts: +(pass) hardenOpencodeAgentNodeEnv > restores launcher PATH and strips every pre-entrypoint loader hook [2.00ms] +(pass) hardenOpencodeAgentNodeEnv > does not mutate the caller's env object +(pass) hardenOpencodeAgentNodeEnv > strips case-variant loader and PATH keys for Windows semantics [1.00ms] + +src/opencode-auth-login.test.ts: +(pass) OpenCode manual auth-login sandbox > builds deterministic provider-specific API-key login argv +(pass) OpenCode manual auth-login sandbox > uses a fresh all-XDG tree and strips ambient credentials/config hooks [27.00ms] +(pass) OpenCode manual auth-login sandbox > strictly consumes only the selected provider API record through a private leaf [21.00ms] +(pass) OpenCode manual auth-login sandbox > refuses OAuth, mixed-provider and symlink auth shapes without disclosing secrets [24.00ms] +(pass) OpenCode manual auth-login sandbox > persistent planted DB/log links are never exposed and cleanup never follows descendant links [21.00ms] +(pass) OpenCode manual auth-login sandbox > cleanup unlinks a swapped root symlink but never removes its outside target [27.00ms] +(pass) OpenCode manual auth-login sandbox > cleanup quarantines the tracked inode but leaves a regular root-name replacement untouched [26.00ms] +(pass) OpenCode manual auth-login sandbox > a live tracked root whose literal name ends in deleted is still removed [20.00ms] +(pass) OpenCode manual auth-login sandbox > Linux reports nlink zero for a removed directory retained by fd [14.00ms] +(pass) OpenCode manual auth-login sandbox > cleanup retains inode ownership after bounded failure and succeeds on retry [24.00ms] +(pass) OpenCode manual auth-login sandbox > refuses a concurrent live owner marker [22.00ms] +(pass) OpenCode manual auth-login sandbox > refuses a provider that does not match the node's unique configured preset [10.00ms] +(pass) OpenCode manual auth-login sandbox > prunes a dead owner's stale root without following its planted links [28.00ms] +(pass) OpenCode manual auth-login sandbox > PID reuse does not retain a stale credential root [36.00ms] +(pass) OpenCode manual auth-login sandbox > stale sweep resumes a crash-left quarantine while its owner marker remains [36.00ms] +(pass) OpenCode manual auth-login sandbox > stale sweep removes an empty quarantine left after marker-last deletion [19.00ms] +(pass) OpenCode manual auth-login sandbox > spawn-time revalidation rejects a hostile ancestor discovery candidate [21.00ms] +(pass) OpenCode manual auth-login sandbox > with helper always cleans the fresh root when the action throws [22.00ms] + +src/opencode-runtime-binding.test.ts: +(pass) external OpenCode runtime binding > survives regular config runtime downgrade and proves the original exact runtime [13.00ms] +(pass) external OpenCode runtime binding > read returns undefined only for absent state and deterministic keys separate nodes [14.00ms] +(pass) external OpenCode runtime binding > an absent exact leaf does not impose POSIX modes on ordinary runtime state [2.00ms] +(pass) external OpenCode runtime binding > unbound legacy symlink or junction-style node paths remain invisible [10.00ms] +(pass) external OpenCode runtime binding > Windows synthetic permission bits do not disable structural security checks [1.00ms] +(pass) external OpenCode runtime binding > secure removal is idempotent and removes the exact binding [7.00ms] +(pass) external OpenCode runtime binding > secure removal refuses tampered content without unlinking it [6.00ms] +(pass) external OpenCode runtime binding > rejects binding-directory and leaf symlinks [7.00ms] +(pass) external OpenCode runtime binding > rejects dangling binding-root and exact-leaf symlinks [3.00ms] +(pass) external OpenCode runtime binding > rejects permissive modes, hard links, and foreign ownership [8.00ms] +(pass) external OpenCode runtime binding > rejects private but tampered runtime, identity, and extra fields [20.00ms] +(pass) external OpenCode runtime binding > rejects binding roots that overlap the canonical project in either direction [6.00ms] +(pass) external OpenCode runtime binding > a symlinked node workDir cannot remove another project's binding [8.00ms] +(pass) assertOpencodeNodeStateUntracked > allows ordinary non-Git projects [3.00ms] +(pass) assertOpencodeNodeStateUntracked > allows ignored/untracked state but rejects git add -f tracked state [37.00ms] +(pass) assertOpencodeNodeStateUntracked > rejects a force-added dotenv or any tracked file below the node directory [43.00ms] + +src/opencode-preset.test.ts: +(pass) OPENCODE_PRESETS registry > exports the two blessed presets (anthropic + openai) [1.00ms] +(pass) OPENCODE_PRESETS registry > findOpencodePreset('anthropic') returns the record; unknown returns null +(pass) readPresetKeyFromEnv — env-only, no interactive prompt > returns the trimmed key when the env var is set +(pass) readPresetKeyFromEnv — env-only, no interactive prompt > returns null when the env var is missing / empty +(pass) buildAuthJsonBody + writeOpencodeAuthJson > body shape matches opencode auth.json convention [1.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writes to /.local/share/opencode/auth.json with mode 0o600 [10.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writeOpencodeConfigJson lands under .config/opencode with 0o600 [7.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > keyless create atomically clears a private pre-planted auth file [7.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > default tool policy disables filesystem, shell, task, and skill tools +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writes only blessed provider identity and strips all pre-planted routing/executable config [11.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > atomically replaces a private but invalid pre-planted config without parsing it [7.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > rejects symlink escapes in workDir, config/data ancestors, and final targets [12.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > validates the full tree before mutation so a bad auth side cannot partially rewrite config [1.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > rejects permissive modes and foreign owners without chmod-follow repair [8.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > prepares .anet/nodes/node before profile secrets and provides atomic private leaf I/O [31.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > accepts an ordinary 0775 project root for a non-root uid=gid private group [1.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > profile preflight rejects .anet/nodes/node and config/.env symlink chains before secret writes [9.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > profile preflight rejects writable ancestors and non-private node roots [3.00ms] + +src/opencode-smoke-env.test.ts: +(pass) buildOpencodeSmokeEnv > locks the exact hardened ancestor candidate set +(pass) buildOpencodeSmokeEnv > rejects sticky world-writable /tmp instead of silently degrading [1.00ms] +(pass) buildOpencodeSmokeEnv > inherits only transport/locale trust settings and controls all OpenCode roots [2.00ms] +(pass) buildOpencodeSmokeEnv > every writable root can be precreated private, including XDG_RUNTIME_DIR [2.00ms] + +src/opencode-package-binary.test.ts: +(pass) validateOpencodePackageBinary > accepts only the canonical exact npm package entrypoint [3.00ms] +(pass) validateOpencodePackageBinary > rejects a same-version package impersonator inside the project [1.00ms] +(pass) validateOpencodePackageBinary > skips a same-version project shim and selects a later trusted package [4.00ms] +(pass) validateOpencodePackageBinary > rejects a monorepo-root package when invoked from a nested app [3.00ms] +(pass) validateOpencodePackageBinary > ordinary 0664 checkout package.json does not abort boundary discovery [3.00ms] +(pass) validateOpencodePackageBinary > accepts both exact registry spellings of bin.opencode [2.00ms] +(pass) validateOpencodePackageBinary > rejects forged name, version, and bin metadata [3.00ms] +(pass) validateOpencodePackageBinary > rejects world-writable files and package ancestors [3.00ms] +(pass) validateOpencodePackageBinary > rejects a symlinked package.json even when its contents are exact [1.00ms] + + 76 pass + 0 fail + 400 expect() calls +Ran 76 tests across 9 files. [1209.00ms] +PASS: exact pairing plus launch, TERM-only wrapper stop, auth-login, smoke-env, and preset security unit tests +bun test v1.3.1 (89fa0f34) + +src/runtime/opencode-acp/events.test.ts: +(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_message_chunk with text content → replyText += content.text [1.00ms] +(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_thought_chunk with text → thoughtText, NOT replyText (grok discipline) +(pass) reduceOpencodeAcpNotification — session/update dispatch > tool_call and tool_call_update both bump toolCalls +(pass) reduceOpencodeAcpNotification — session/update dispatch > usage_update snaps totalTokens into state.usage +(pass) reduceOpencodeAcpNotification — session/update dispatch > available_commands_update consumed silently (session-init only) +(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_message_chunk without text content adds a warning +(pass) reduceOpencodeAcpNotification — session/update dispatch > unknown method returns ignored without mutating state +(pass) reduceOpencodeAcpNotification — session/update dispatch > unknown sessionUpdate subtype returns ignored (forward-compat) +(pass) reduceOpencodeAcpResponse — session/prompt terminal response > captures stopReason + usage from result [1.00ms] +(pass) reduceOpencodeAcpResponse — session/prompt terminal response > missing stopReason still marks turn complete +(pass) reduceOpencodeAcpFrames — replay the Phase 0b captured turn > full one-word turn: 10 thought chunks + 1 message chunk + usage + response [1.00ms] +(pass) reduceOpencodeAcpFrames — replay the Phase 0b captured turn > thinking-only terminal turn (no agent_message_chunk) — replyText stays empty + +src/runtime/opencode-acp/child-env.test.ts: +(pass) buildOpencodeChildEnv — deny-by-default boundary > locks the exact hardened ancestor candidate set +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects sticky world-writable /tmp instead of silently degrading [6.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > passes only runtime/network allowlist and controls all state roots [26.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > safe inline policy disables every local tool without replacing provider/model [19.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > unsafe opt-in explicitly overrides the wizard's persisted safe policy [11.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > detects exact managed config sources across Linux, Windows, and macOS [1.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > safe runtime renders ordinary same-uid config through a strict allowlist [23.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > copies only blessed API auth fields into fresh data and keeps persistent state outside the child [19.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > never exposes planted persistent DB/log/cache/state/tmp descendants in safe or unsafe mode [26.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > removes a partially built launch tree when env construction fails [17.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > pre-spawn revalidation hard-fails when an ancestor discovery candidate appears [20.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > keeps active roots but reclaims a dead-owner crash root without following symlinks [45.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > reclaims dead-owner roots after the node workDir is deleted or recreated [67.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > a transient cleanup pathname swap is retried after child exit [37.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > a dead owner marker is retained while an orphan child still references the root [64.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > durable process-group binding protects opaque descendants across later and crash-style sweeps [177.01ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > an exact exited-process identity exemption never hides a live descendant or PID mismatch [57.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects symlinks at workDir and every security-sensitive state layer [15.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects permissive modes and foreign ownership without repairing them [2.00ms] + +src/runtime/opencode-acp/profile-state.test.ts: +(pass) OpenCode private profile state > loads, atomically updates, backs up, and writes a session [12.00ms] +(pass) OpenCode private profile state > a post-load config symlink cannot redirect session writeback +(pass) OpenCode private profile state > boot refuses a config symlink before self-heal can write its target [1.00ms] +(pass) OpenCode private profile state > backup refuses a pre-planted .prev symlink [1.00ms] +(pass) OpenCode private profile state > runtime hint rejects suspicious config leaves for every runtime [1.00ms] + +src/runtime/opencode-acp/client.test.ts: +(pass) OpencodeAcpClient — request/response correlation > request() resolves with the matching response's result [162.01ms] +(pass) OpencodeAcpClient — request/response correlation > error response rejects the promise with a shaped message [159.01ms] +(pass) OpencodeAcpClient — streaming notifications > emits 'notification' for every session/update frame [185.01ms] +(pass) OpencodeAcpClient — streaming notifications > id-carrying reverse requests get an explicit method-not-found response [142.01ms] +(pass) OpencodeAcpClient — process lifecycle > supervisor receipt is ready before the vendor can inherit launch state [117.00ms] +(pass) OpencodeAcpClient — process lifecycle > child exit rejects all pending requests [181.01ms] +(pass) OpencodeAcpClient — process lifecycle > isRunning flips false after stop() [95.00ms] +(pass) OpencodeAcpClient — process lifecycle > a crashing group leader cannot leave a live descendant behind [218.01ms] +(pass) OpencodeAcpClient — process lifecycle > SIGSTOP supervisor makes stop fail closed until the exact owner resumes [371.02ms] +(pass) OpencodeAcpClient — process lifecycle > external supervisor SIGKILL never triggers a stale PGID kill or clean exit [1677.07ms] +(pass) OpencodeAcpClient — process lifecycle > concurrent stop callers share one verified public exit [70.00ms] +(pass) OpencodeAcpClient — process lifecycle > explicit child env is not merged with the client's process.env [158.01ms] + +src/runtime/opencode-acp/runtime.test.ts: +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — cwd and tool policy > safe default keeps spawn + ACP session in one external launch workspace [216.01ms] +[opencode-acp] session/new — ses_probe_au... +(pass) openOpencodeRuntime — cwd and tool policy > version probe root is credential-free and gone before runtime auth is materialized [191.01ms] +[opencode-acp] session/load ok — resumed ses_existing... +(pass) openOpencodeRuntime — cwd and tool policy > safe session/load reuses the exact spawn PWD as its ACP cwd [295.01ms] +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — cwd and tool policy > explicit unsafe flag restores project cwd and emits a trusted-task warning [264.01ms] +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — opening lifecycle > normal stop removes the launch root and copied vendor auth [302.01ms] +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — opening lifecycle > repeated open/stop cycles do not accumulate launch roots [6364.27ms] +(pass) openOpencodeRuntime — opening lifecycle > an ancestor candidate planted by the version probe hard-fails before ACP spawn [90.00ms] +(pass) openOpencodeRuntime — opening lifecycle > package replacement after credential-free probe is rejected and runtime auth root is discarded [98.00ms] +(pass) openOpencodeRuntime — opening lifecycle > in-place binary self-modification after probe is rejected before credential spawn [104.00ms] +(pass) openOpencodeRuntime — opening lifecycle > production rejects canonical same-version packages below project cwd or node workDir [32.00ms] +(pass) openOpencodeRuntime — opening lifecycle > initialize failure force-kills the child before rejecting [235.01ms] +(pass) openOpencodeRuntime — opening lifecycle > onClient exposes a stalled-handshake child synchronously for shutdown [133.01ms] +[opencode-acp] session/new — ses_idle... +(pass) opencodeThink — failed-turn lifecycle > prompt idle timeout force-kills the child before rejecting [261.01ms] +[opencode-acp] session/new — ses_rescue_i... +[opencode-acp] #383 thinking-only terminal turn (chunks=0 thoughtChunks=1) — re-prompting for plain-text final +(pass) opencodeThink — failed-turn lifecycle > a failed thinking-only rescue discards the child before returning [252.01ms] + +src/runtime/opencode-acp/binary.test.ts: +(pass) resolvePinnedOpencodeBinary > locks the non-root uid=gid umask-0002 compatibility policy +(pass) resolvePinnedOpencodeBinary > accepts the canonical package entrypoint and probes it from the external cwd [64.00ms] +(pass) resolvePinnedOpencodeBinary > accepts an npm-style PATH shim but returns the canonical package binary [40.00ms] +(pass) resolvePinnedOpencodeBinary > rejects a same-version fake package inside the project before executing it [2.00ms] +(pass) resolvePinnedOpencodeBinary > rejects forged package metadata and noncanonical entrypoints [4.00ms] +(pass) resolvePinnedOpencodeBinary > rejects unsafe file, package-directory, ancestor, and owner modes [6.00ms] +(pass) resolvePinnedOpencodeBinary > still enforces exact --version output after package identity succeeds [42.00ms] +(pass) resolvePinnedOpencodeBinary > refuses a caller-selected version other than the vetted release pin [1.00ms] +(pass) resolvePinnedOpencodeBinary > rejects a same-version package in a monorepo ancestor before probing it [2.00ms] +(pass) resolvePinnedOpencodeBinary > discovers a workspace ancestor when the configured project leaf is absent [1.00ms] +(pass) resolvePinnedOpencodeBinary > launcher absolute path wins over a hostile search PATH [28.00ms] +(pass) resolvePinnedOpencodeBinary > rejects non-absolute overrides + + 74 pass + 0 fail + 722 expect() calls +Ran 74 tests across 6 files. [13.34s] +PASS: agent-node OpenCode package, child-env, profile-state, ACP client/events, and runtime unit tests +Error: opencode safe mode refuses OS-managed config source /etc/opencode/opencode.json; managed MCP/tools load after inline config +PASS: safe runtime rejects exact /etc/opencode managed source before hostile MCP/version spawn +$ tsc --noEmit +PASS: agent-network typecheck +$ bun build src/client.ts --outdir dist/src --target node --minify && bun build bin/cli.ts --outdir dist/bin --target node --minify --external @sleep2agi/commhub-server --external bun:sqlite --external '../../server/*' && bun build src/node-server.ts --outdir dist/src --target node --minify --external @modelcontextprotocol/sdk && bun build src/im/feishu/worker.ts --outdir dist/src/im/feishu --target node --minify --external @larksuiteoapi/node-sdk && tsc --emitDeclarationOnly --declaration --outDir dist && bun x javascript-obfuscator dist/bin/cli.js --output dist/bin/cli.js --compact true --string-array true --string-array-encoding base64 && bun x javascript-obfuscator dist/src/client.js --output dist/src/client.js --compact true --string-array true && bun x javascript-obfuscator dist/src/node-server.js --output dist/src/node-server.js --compact true --string-array true +Bundled 1 module in 13ms + + client.js 3.67 KB (entry point) + +Bundled 94 modules in 94ms + + cli.js 0.66 MB (entry point) + +Bundled 2 modules in 8ms + + node-server.js 11.15 KB (entry point) + +Bundled 597 modules in 201ms + + worker.js 2.29 MB (entry point) + + +[javascript-obfuscator-cli] Obfuscating file: dist/bin/cli.js... + +[javascript-obfuscator-cli] Obfuscating file: dist/src/client.js... + +[javascript-obfuscator-cli] Obfuscating file: dist/src/node-server.js... +PASS: agent-network release bundle build +PASS network validator: /home/bun/prefix/node_modules/opencode-ai/bin/opencode.exe +PASS agent-node validator + version probe: /home/bun/prefix/node_modules/opencode-ai/bin/opencode.exe +PASS private uid=gid group-write paths: 6 +PASS: non-root uid=gid=1000 umask-0002 real opencode-ai@1.18.1 passes network and agent-node gates +[anet] OpenCode anthropic API-key login for 'auth-gate' (exact opencode-ai@1.18.1, fresh private state). +[anet] Persistent auth changes only after upstream exits 0 and the credential shape validates. +[anet] ✗ OpenCode auth-login failed; persistent auth unchanged: upstream login exited without success (code=64 signal=none) +PASS: failed upstream auth-login exits nonzero, preserves old auth, and cleans its disposable root +[anet] Starting new session for "gate" [opencode-cli]... + +[anet] using installed exact @sleep2agi/agent-node@2.5.0-preview.27. +[anet] Token: [REDACTED_TOKEN] +PASS: stale global bypassed; later exact global received protected PATH/binary/version/base; npx was not executed +[anet] Starting new session for "gate" [opencode-cli]... + +[anet] using installed exact @sleep2agi/agent-node@2.5.0-preview.27. +[anet] Token: [REDACTED_TOKEN] +PASS: same-version project-local OpenCode/agent-node payloads skipped; canonical external packages launched +[anet] Starting new session for "gate" [opencode-cli]... + +[anet] Incompatible agent-node for opencode-cli. +[anet] ANET_AGENT_NODE_BIN is not the exact trusted @sleep2agi/agent-node@2.5.0-preview.27: project/node-local agent-node package payload is not trusted +Install the exact vetted pair: npm install -g @sleep2agi/agent-network@2.3.0-preview.35 @sleep2agi/agent-node@2.5.0-preview.27 +[anet] Refusing to start: an unsupported agent-node could silently select another runtime. +PASS: explicit exact project-local agent-node is rejected before execution and without npx +[anet] Starting new session for "gate" [opencode-cli]... + +[anet] using installed exact @sleep2agi/agent-node@2.5.0-preview.27. +[anet] Token: [REDACTED_TOKEN] +PASS: capable-looking global preview.21 rejected; later exact global preview.26 launched without npx +[anet] Starting new session for "gate" [opencode-cli]... + +[anet] Incompatible agent-node for opencode-cli. +[anet] ANET_AGENT_NODE_BIN is not the exact trusted @sleep2agi/agent-node@2.5.0-preview.27: resolved agent-node package is not exact version 2.5.0-preview.27 +Install the exact vetted pair: npm install -g @sleep2agi/agent-network@2.3.0-preview.35 @sleep2agi/agent-node@2.5.0-preview.27 +[anet] Refusing to start: an unsupported agent-node could silently select another runtime. +PASS: ANET_AGENT_NODE_BIN cannot bypass the exact hardened pair +[anet] Starting new session for "gate" [opencode-cli]... + +[anet] Incompatible agent-node for opencode-cli. +[anet] No exact trusted global @sleep2agi/agent-node@2.5.0-preview.27 is available (no exact trusted @sleep2agi/agent-node@2.5.0-preview.27 package entrypoint found on PATH; first rejected candidate: @sleep2agi/agent-node@2.5.0-preview.27 entrypoint is outside node_modules/@sleep2agi/agent-node); automatic npx execution is disabled for opencode-cli +Install the exact vetted pair: npm install -g @sleep2agi/agent-network@2.3.0-preview.35 @sleep2agi/agent-node@2.5.0-preview.27 +[anet] Refusing to start: an unsupported agent-node could silently select another runtime. +PASS: no exact global rejects startup, executes no npx, and prints exact dual-package remediation + +OVERALL: PASS diff --git a/docs/tests/report-test386-stop-orphan-fail.txt b/docs/tests/report-test386-stop-orphan-fail.txt new file mode 100644 index 00000000..5a8e2681 --- /dev/null +++ b/docs/tests/report-test386-stop-orphan-fail.txt @@ -0,0 +1,259 @@ +# Test 386 — stop-orphan first run (expected failure history) + +- date: 2026-07-16T06:22:57+00:00 +bun test v1.3.1 (89fa0f34) + +src/opencode-wrapper-stop.test.ts: +(pass) bound OpenCode wrapper TERM-only stop > a responsive wrapper exits after SIGTERM [18.00ms] +(pass) bound OpenCode wrapper TERM-only stop > a parent-forwarded SIGINT retains a SIGSTOP'd wrapper and detached child [163.01ms] + +src/opencode-owner-mode.test.ts: +(pass) OpenCode owner/mode policy > accepts umask-0002 modes only for a non-root uid=gid layout [2.00ms] +(pass) OpenCode owner/mode policy > always rejects world write and keeps root/foreign ownership strict + +src/opencode-agent-node-pair.test.ts: +(pass) OpenCode agent-node release pairing > pins the exact versions being released together +(pass) OpenCode agent-node release pairing > rejects latest 2.4.x-style help and accepts the RFC-029 capability +(pass) OpenCode agent-node release pairing > admits only the exact preview package identity with safe file modes [6.00ms] +(pass) OpenCode agent-node release pairing > skips an exact project-local impersonator and selects the later global package [8.00ms] + +src/opencode-launch-env.test.ts: +(pass) hardenOpencodeAgentNodeEnv > restores launcher PATH and strips every pre-entrypoint loader hook [2.00ms] +(pass) hardenOpencodeAgentNodeEnv > does not mutate the caller's env object +(pass) hardenOpencodeAgentNodeEnv > strips case-variant loader and PATH keys for Windows semantics [1.00ms] + +src/opencode-auth-login.test.ts: +(pass) OpenCode manual auth-login sandbox > builds deterministic provider-specific API-key login argv +(pass) OpenCode manual auth-login sandbox > uses a fresh all-XDG tree and strips ambient credentials/config hooks [30.00ms] +(pass) OpenCode manual auth-login sandbox > strictly consumes only the selected provider API record through a private leaf [19.00ms] +(pass) OpenCode manual auth-login sandbox > refuses OAuth, mixed-provider and symlink auth shapes without disclosing secrets [23.00ms] +(pass) OpenCode manual auth-login sandbox > persistent planted DB/log links are never exposed and cleanup never follows descendant links [20.00ms] +(pass) OpenCode manual auth-login sandbox > cleanup unlinks a swapped root symlink but never removes its outside target [17.00ms] +(pass) OpenCode manual auth-login sandbox > cleanup quarantines the tracked inode but leaves a regular root-name replacement untouched [21.00ms] +(pass) OpenCode manual auth-login sandbox > a live tracked root whose literal name ends in deleted is still removed [21.00ms] +(pass) OpenCode manual auth-login sandbox > Linux reports nlink zero for a removed directory retained by fd [8.00ms] +(pass) OpenCode manual auth-login sandbox > cleanup retains inode ownership after bounded failure and succeeds on retry [21.00ms] +(pass) OpenCode manual auth-login sandbox > refuses a concurrent live owner marker [23.00ms] +(pass) OpenCode manual auth-login sandbox > refuses a provider that does not match the node's unique configured preset [10.00ms] +(pass) OpenCode manual auth-login sandbox > prunes a dead owner's stale root without following its planted links [29.00ms] +(pass) OpenCode manual auth-login sandbox > PID reuse does not retain a stale credential root [36.00ms] +(pass) OpenCode manual auth-login sandbox > stale sweep resumes a crash-left quarantine while its owner marker remains [39.00ms] +(pass) OpenCode manual auth-login sandbox > stale sweep removes an empty quarantine left after marker-last deletion [22.00ms] +(pass) OpenCode manual auth-login sandbox > spawn-time revalidation rejects a hostile ancestor discovery candidate [21.00ms] +(pass) OpenCode manual auth-login sandbox > with helper always cleans the fresh root when the action throws [22.00ms] + +src/opencode-runtime-binding.test.ts: +(pass) external OpenCode runtime binding > survives regular config runtime downgrade and proves the original exact runtime [14.00ms] +(pass) external OpenCode runtime binding > read returns undefined only for absent state and deterministic keys separate nodes [9.00ms] +(pass) external OpenCode runtime binding > an absent exact leaf does not impose POSIX modes on ordinary runtime state [2.00ms] +(pass) external OpenCode runtime binding > unbound legacy symlink or junction-style node paths remain invisible [8.00ms] +(pass) external OpenCode runtime binding > Windows synthetic permission bits do not disable structural security checks [3.00ms] +(pass) external OpenCode runtime binding > secure removal is idempotent and removes the exact binding [8.00ms] +(pass) external OpenCode runtime binding > secure removal refuses tampered content without unlinking it [5.00ms] +(pass) external OpenCode runtime binding > rejects binding-directory and leaf symlinks [5.00ms] +(pass) external OpenCode runtime binding > rejects dangling binding-root and exact-leaf symlinks [3.00ms] +(pass) external OpenCode runtime binding > rejects permissive modes, hard links, and foreign ownership [8.00ms] +(pass) external OpenCode runtime binding > rejects private but tampered runtime, identity, and extra fields [9.00ms] +(pass) external OpenCode runtime binding > rejects binding roots that overlap the canonical project in either direction [5.00ms] +(pass) external OpenCode runtime binding > a symlinked node workDir cannot remove another project's binding [11.00ms] +(pass) assertOpencodeNodeStateUntracked > allows ordinary non-Git projects [2.00ms] +(pass) assertOpencodeNodeStateUntracked > allows ignored/untracked state but rejects git add -f tracked state [23.00ms] +(pass) assertOpencodeNodeStateUntracked > rejects a force-added dotenv or any tracked file below the node directory [31.00ms] + +src/opencode-preset.test.ts: +(pass) OPENCODE_PRESETS registry > exports the two blessed presets (anthropic + openai) +(pass) OPENCODE_PRESETS registry > findOpencodePreset('anthropic') returns the record; unknown returns null +(pass) readPresetKeyFromEnv — env-only, no interactive prompt > returns the trimmed key when the env var is set +(pass) readPresetKeyFromEnv — env-only, no interactive prompt > returns null when the env var is missing / empty +(pass) buildAuthJsonBody + writeOpencodeAuthJson > body shape matches opencode auth.json convention [1.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writes to /.local/share/opencode/auth.json with mode 0o600 [9.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writeOpencodeConfigJson lands under .config/opencode with 0o600 [7.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > keyless create atomically clears a private pre-planted auth file [7.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > default tool policy disables filesystem, shell, task, and skill tools +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writes only blessed provider identity and strips all pre-planted routing/executable config [6.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > atomically replaces a private but invalid pre-planted config without parsing it [6.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > rejects symlink escapes in workDir, config/data ancestors, and final targets [6.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > validates the full tree before mutation so a bad auth side cannot partially rewrite config [1.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > rejects permissive modes and foreign owners without chmod-follow repair [4.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > prepares .anet/nodes/node before profile secrets and provides atomic private leaf I/O [23.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > accepts an ordinary 0775 project root for a non-root uid=gid private group [1.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > profile preflight rejects .anet/nodes/node and config/.env symlink chains before secret writes [6.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > profile preflight rejects writable ancestors and non-private node roots [2.00ms] + +src/opencode-smoke-env.test.ts: +(pass) buildOpencodeSmokeEnv > locks the exact hardened ancestor candidate set [2.00ms] +(pass) buildOpencodeSmokeEnv > rejects sticky world-writable /tmp instead of silently degrading +(pass) buildOpencodeSmokeEnv > inherits only transport/locale trust settings and controls all OpenCode roots [2.00ms] +(pass) buildOpencodeSmokeEnv > every writable root can be precreated private, including XDG_RUNTIME_DIR [2.00ms] + +src/opencode-package-binary.test.ts: +(pass) validateOpencodePackageBinary > accepts only the canonical exact npm package entrypoint [4.00ms] +(pass) validateOpencodePackageBinary > rejects a same-version package impersonator inside the project [2.00ms] +(pass) validateOpencodePackageBinary > skips a same-version project shim and selects a later trusted package [3.00ms] +(pass) validateOpencodePackageBinary > rejects a monorepo-root package when invoked from a nested app [3.00ms] +(pass) validateOpencodePackageBinary > ordinary 0664 checkout package.json does not abort boundary discovery [1.00ms] +(pass) validateOpencodePackageBinary > accepts both exact registry spellings of bin.opencode [3.00ms] +(pass) validateOpencodePackageBinary > rejects forged name, version, and bin metadata [3.00ms] +(pass) validateOpencodePackageBinary > rejects world-writable files and package ancestors [2.00ms] +(pass) validateOpencodePackageBinary > rejects a symlinked package.json even when its contents are exact [1.00ms] + + 76 pass + 0 fail + 400 expect() calls +Ran 76 tests across 9 files. [993.00ms] +PASS: exact pairing plus launch, TERM-only wrapper stop, auth-login, smoke-env, and preset security unit tests +bun test v1.3.1 (89fa0f34) + +src/runtime/opencode-acp/events.test.ts: +(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_message_chunk with text content → replyText += content.text +(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_thought_chunk with text → thoughtText, NOT replyText (grok discipline) [1.00ms] +(pass) reduceOpencodeAcpNotification — session/update dispatch > tool_call and tool_call_update both bump toolCalls +(pass) reduceOpencodeAcpNotification — session/update dispatch > usage_update snaps totalTokens into state.usage +(pass) reduceOpencodeAcpNotification — session/update dispatch > available_commands_update consumed silently (session-init only) +(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_message_chunk without text content adds a warning +(pass) reduceOpencodeAcpNotification — session/update dispatch > unknown method returns ignored without mutating state +(pass) reduceOpencodeAcpNotification — session/update dispatch > unknown sessionUpdate subtype returns ignored (forward-compat) +(pass) reduceOpencodeAcpResponse — session/prompt terminal response > captures stopReason + usage from result +(pass) reduceOpencodeAcpResponse — session/prompt terminal response > missing stopReason still marks turn complete +(pass) reduceOpencodeAcpFrames — replay the Phase 0b captured turn > full one-word turn: 10 thought chunks + 1 message chunk + usage + response [3.00ms] +(pass) reduceOpencodeAcpFrames — replay the Phase 0b captured turn > thinking-only terminal turn (no agent_message_chunk) — replyText stays empty + +src/runtime/opencode-acp/child-env.test.ts: +(pass) buildOpencodeChildEnv — deny-by-default boundary > locks the exact hardened ancestor candidate set [1.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects sticky world-writable /tmp instead of silently degrading [8.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > passes only runtime/network allowlist and controls all state roots [23.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > safe inline policy disables every local tool without replacing provider/model [25.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > unsafe opt-in explicitly overrides the wizard's persisted safe policy [11.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > detects exact managed config sources across Linux, Windows, and macOS [4.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > safe runtime renders ordinary same-uid config through a strict allowlist [22.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > copies only blessed API auth fields into fresh data and keeps persistent state outside the child [24.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > never exposes planted persistent DB/log/cache/state/tmp descendants in safe or unsafe mode [37.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > removes a partially built launch tree when env construction fails [15.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > pre-spawn revalidation hard-fails when an ancestor discovery candidate appears [16.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > keeps active roots but reclaims a dead-owner crash root without following symlinks [44.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > reclaims dead-owner roots after the node workDir is deleted or recreated [78.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > a transient cleanup pathname swap is retried after child exit [28.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > a dead owner marker is retained while an orphan child still references the root [72.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > durable process-group binding protects opaque descendants across later and crash-style sweeps [147.01ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > an exact exited-process identity exemption never hides a live descendant or PID mismatch [101.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects symlinks at workDir and every security-sensitive state layer [19.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects permissive modes and foreign ownership without repairing them [2.00ms] + +src/runtime/opencode-acp/profile-state.test.ts: +(pass) OpenCode private profile state > loads, atomically updates, backs up, and writes a session [14.00ms] +(pass) OpenCode private profile state > a post-load config symlink cannot redirect session writeback [1.00ms] +(pass) OpenCode private profile state > boot refuses a config symlink before self-heal can write its target [1.00ms] +(pass) OpenCode private profile state > backup refuses a pre-planted .prev symlink [1.00ms] +(pass) OpenCode private profile state > runtime hint rejects suspicious config leaves for every runtime [1.00ms] + +src/runtime/opencode-acp/client.test.ts: +(pass) OpencodeAcpClient — request/response correlation > request() resolves with the matching response's result [146.01ms] +(pass) OpencodeAcpClient — request/response correlation > error response rejects the promise with a shaped message [158.01ms] +(pass) OpencodeAcpClient — streaming notifications > emits 'notification' for every session/update frame [161.01ms] +(pass) OpencodeAcpClient — streaming notifications > id-carrying reverse requests get an explicit method-not-found response [143.01ms] +(pass) OpencodeAcpClient — process lifecycle > supervisor receipt is ready before the vendor can inherit launch state [130.01ms] +(pass) OpencodeAcpClient — process lifecycle > child exit rejects all pending requests [151.01ms] +(pass) OpencodeAcpClient — process lifecycle > isRunning flips false after stop() [88.00ms] +(pass) OpencodeAcpClient — process lifecycle > a crashing group leader cannot leave a live descendant behind [195.01ms] +(pass) OpencodeAcpClient — process lifecycle > SIGSTOP supervisor makes stop fail closed until the exact owner resumes [332.01ms] +367 | try { process.kill(vendorPid, "SIGKILL"); } catch {} +368 | await waitForFixture(() => !pidIsLive(vendorPid)); +369 | await c.stop("SIGTERM", 2_000).catch(() => {}); +370 | rmSync(vendorPidFile, { force: true }); +371 | } +372 | expect(c.cleanupConfirmed).toBe(true); + ^ +error: expect(received).toBe(expected) + +Expected: true +Received: false + + at (/repo/agent-node/src/runtime/opencode-acp/client.test.ts:372:32) +(fail) OpencodeAcpClient — process lifecycle > external supervisor SIGKILL never triggers a stale PGID kill or clean exit [3168.13ms] +(pass) OpencodeAcpClient — process lifecycle > concurrent stop callers share one verified public exit [65.00ms] +(pass) OpencodeAcpClient — process lifecycle > explicit child env is not merged with the client's process.env [130.01ms] + +src/runtime/opencode-acp/runtime.test.ts: +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — cwd and tool policy > safe default keeps spawn + ACP session in one external launch workspace [218.01ms] +[opencode-acp] session/new — ses_probe_au... +(pass) openOpencodeRuntime — cwd and tool policy > version probe root is credential-free and gone before runtime auth is materialized [223.01ms] +[opencode-acp] session/load ok — resumed ses_existing... +(pass) openOpencodeRuntime — cwd and tool policy > safe session/load reuses the exact spawn PWD as its ACP cwd [226.01ms] +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — cwd and tool policy > explicit unsafe flag restores project cwd and emits a trusted-task warning [215.01ms] +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — opening lifecycle > normal stop removes the launch root and copied vendor auth [230.01ms] +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — opening lifecycle > repeated open/stop cycles do not accumulate launch roots [5978.25ms] +(pass) openOpencodeRuntime — opening lifecycle > an ancestor candidate planted by the version probe hard-fails before ACP spawn [83.00ms] +(pass) openOpencodeRuntime — opening lifecycle > package replacement after credential-free probe is rejected and runtime auth root is discarded [85.00ms] +(pass) openOpencodeRuntime — opening lifecycle > in-place binary self-modification after probe is rejected before credential spawn [80.00ms] +(pass) openOpencodeRuntime — opening lifecycle > production rejects canonical same-version packages below project cwd or node workDir [32.00ms] +(pass) openOpencodeRuntime — opening lifecycle > initialize failure force-kills the child before rejecting [214.01ms] +660 | expect(exposed!.isRunning).toBe(true); +661 | await exposed!.stop("SIGKILL"); +662 | let thrown: Error | null = null; +663 | try { await opening; } +664 | catch (error: any) { thrown = error; } +665 | expect(thrown?.message).toContain("opencode acp exited"); + ^ +error: expect(received).toContain(expected) + +Expected to contain: "opencode acp exited" +Received: "OpenCode supervisor exited before childStarted" + + at (/repo/agent-node/src/runtime/opencode-acp/runtime.test.ts:665:31) +(fail) openOpencodeRuntime — opening lifecycle > onClient exposes a stalled-handshake child synchronously for shutdown [137.01ms] +[opencode-acp] session/new — ses_idle... +(pass) opencodeThink — failed-turn lifecycle > prompt idle timeout force-kills the child before rejecting [256.01ms] +[opencode-acp] session/new — ses_rescue_i... +[opencode-acp] #383 thinking-only terminal turn (chunks=0 thoughtChunks=1) — re-prompting for plain-text final +(pass) opencodeThink — failed-turn lifecycle > a failed thinking-only rescue discards the child before returning [268.01ms] + +src/runtime/opencode-acp/binary.test.ts: +(pass) resolvePinnedOpencodeBinary > locks the non-root uid=gid umask-0002 compatibility policy +(pass) resolvePinnedOpencodeBinary > accepts the canonical package entrypoint and probes it from the external cwd [47.00ms] +(pass) resolvePinnedOpencodeBinary > accepts an npm-style PATH shim but returns the canonical package binary [25.00ms] +(pass) resolvePinnedOpencodeBinary > rejects a same-version fake package inside the project before executing it [1.00ms] +(pass) resolvePinnedOpencodeBinary > rejects forged package metadata and noncanonical entrypoints [5.00ms] +(pass) resolvePinnedOpencodeBinary > rejects unsafe file, package-directory, ancestor, and owner modes [6.00ms] +(pass) resolvePinnedOpencodeBinary > still enforces exact --version output after package identity succeeds [28.00ms] +(pass) resolvePinnedOpencodeBinary > refuses a caller-selected version other than the vetted release pin [1.00ms] +(pass) resolvePinnedOpencodeBinary > rejects a same-version package in a monorepo ancestor before probing it [3.00ms] +(pass) resolvePinnedOpencodeBinary > discovers a workspace ancestor when the configured project leaf is absent [1.00ms] +(pass) resolvePinnedOpencodeBinary > launcher absolute path wins over a hostile search PATH [25.00ms] +(pass) resolvePinnedOpencodeBinary > rejects non-absolute overrides [1.00ms] + +2 tests failed: +(fail) OpencodeAcpClient — process lifecycle > external supervisor SIGKILL never triggers a stale PGID kill or clean exit [3168.13ms] +(fail) openOpencodeRuntime — opening lifecycle > onClient exposes a stalled-handshake child synchronously for shutdown [137.01ms] + + 72 pass + 2 fail + 717 expect() calls +Ran 74 tests across 6 files. [14.09s] diff --git a/docs/tests/report-test386-stop-orphan-fail2.txt b/docs/tests/report-test386-stop-orphan-fail2.txt new file mode 100644 index 00000000..2978f365 --- /dev/null +++ b/docs/tests/report-test386-stop-orphan-fail2.txt @@ -0,0 +1,244 @@ +# Test 386 — stop-orphan second run (expected failure history) + +- date: 2026-07-16T06:25:41+00:00 +bun test v1.3.1 (89fa0f34) + +src/opencode-wrapper-stop.test.ts: +(pass) bound OpenCode wrapper TERM-only stop > a responsive wrapper exits after SIGTERM [25.00ms] +(pass) bound OpenCode wrapper TERM-only stop > a parent-forwarded SIGINT retains a SIGSTOP'd wrapper and detached child [173.01ms] + +src/opencode-owner-mode.test.ts: +(pass) OpenCode owner/mode policy > accepts umask-0002 modes only for a non-root uid=gid layout [2.00ms] +(pass) OpenCode owner/mode policy > always rejects world write and keeps root/foreign ownership strict + +src/opencode-agent-node-pair.test.ts: +(pass) OpenCode agent-node release pairing > pins the exact versions being released together [2.00ms] +(pass) OpenCode agent-node release pairing > rejects latest 2.4.x-style help and accepts the RFC-029 capability +(pass) OpenCode agent-node release pairing > admits only the exact preview package identity with safe file modes [7.00ms] +(pass) OpenCode agent-node release pairing > skips an exact project-local impersonator and selects the later global package [5.00ms] + +src/opencode-launch-env.test.ts: +(pass) hardenOpencodeAgentNodeEnv > restores launcher PATH and strips every pre-entrypoint loader hook [2.00ms] +(pass) hardenOpencodeAgentNodeEnv > does not mutate the caller's env object +(pass) hardenOpencodeAgentNodeEnv > strips case-variant loader and PATH keys for Windows semantics + +src/opencode-auth-login.test.ts: +(pass) OpenCode manual auth-login sandbox > builds deterministic provider-specific API-key login argv [1.00ms] +(pass) OpenCode manual auth-login sandbox > uses a fresh all-XDG tree and strips ambient credentials/config hooks [36.00ms] +(pass) OpenCode manual auth-login sandbox > strictly consumes only the selected provider API record through a private leaf [22.00ms] +(pass) OpenCode manual auth-login sandbox > refuses OAuth, mixed-provider and symlink auth shapes without disclosing secrets [24.00ms] +(pass) OpenCode manual auth-login sandbox > persistent planted DB/log links are never exposed and cleanup never follows descendant links [22.00ms] +(pass) OpenCode manual auth-login sandbox > cleanup unlinks a swapped root symlink but never removes its outside target [15.00ms] +(pass) OpenCode manual auth-login sandbox > cleanup quarantines the tracked inode but leaves a regular root-name replacement untouched [19.00ms] +(pass) OpenCode manual auth-login sandbox > a live tracked root whose literal name ends in deleted is still removed [19.00ms] +(pass) OpenCode manual auth-login sandbox > Linux reports nlink zero for a removed directory retained by fd [9.00ms] +(pass) OpenCode manual auth-login sandbox > cleanup retains inode ownership after bounded failure and succeeds on retry [15.00ms] +(pass) OpenCode manual auth-login sandbox > refuses a concurrent live owner marker [21.00ms] +(pass) OpenCode manual auth-login sandbox > refuses a provider that does not match the node's unique configured preset [11.00ms] +(pass) OpenCode manual auth-login sandbox > prunes a dead owner's stale root without following its planted links [31.00ms] +(pass) OpenCode manual auth-login sandbox > PID reuse does not retain a stale credential root [33.00ms] +(pass) OpenCode manual auth-login sandbox > stale sweep resumes a crash-left quarantine while its owner marker remains [36.00ms] +(pass) OpenCode manual auth-login sandbox > stale sweep removes an empty quarantine left after marker-last deletion [16.00ms] +(pass) OpenCode manual auth-login sandbox > spawn-time revalidation rejects a hostile ancestor discovery candidate [16.00ms] +(pass) OpenCode manual auth-login sandbox > with helper always cleans the fresh root when the action throws [17.00ms] + +src/opencode-runtime-binding.test.ts: +(pass) external OpenCode runtime binding > survives regular config runtime downgrade and proves the original exact runtime [11.00ms] +(pass) external OpenCode runtime binding > read returns undefined only for absent state and deterministic keys separate nodes [8.00ms] +(pass) external OpenCode runtime binding > an absent exact leaf does not impose POSIX modes on ordinary runtime state [1.00ms] +(pass) external OpenCode runtime binding > unbound legacy symlink or junction-style node paths remain invisible [5.00ms] +(pass) external OpenCode runtime binding > Windows synthetic permission bits do not disable structural security checks +(pass) external OpenCode runtime binding > secure removal is idempotent and removes the exact binding [7.00ms] +(pass) external OpenCode runtime binding > secure removal refuses tampered content without unlinking it [6.00ms] +(pass) external OpenCode runtime binding > rejects binding-directory and leaf symlinks [7.00ms] +(pass) external OpenCode runtime binding > rejects dangling binding-root and exact-leaf symlinks [4.00ms] +(pass) external OpenCode runtime binding > rejects permissive modes, hard links, and foreign ownership [11.00ms] +(pass) external OpenCode runtime binding > rejects private but tampered runtime, identity, and extra fields [8.00ms] +(pass) external OpenCode runtime binding > rejects binding roots that overlap the canonical project in either direction [4.00ms] +(pass) external OpenCode runtime binding > a symlinked node workDir cannot remove another project's binding [8.00ms] +(pass) assertOpencodeNodeStateUntracked > allows ordinary non-Git projects [2.00ms] +(pass) assertOpencodeNodeStateUntracked > allows ignored/untracked state but rejects git add -f tracked state [25.00ms] +(pass) assertOpencodeNodeStateUntracked > rejects a force-added dotenv or any tracked file below the node directory [54.00ms] + +src/opencode-preset.test.ts: +(pass) OPENCODE_PRESETS registry > exports the two blessed presets (anthropic + openai) [2.00ms] +(pass) OPENCODE_PRESETS registry > findOpencodePreset('anthropic') returns the record; unknown returns null +(pass) readPresetKeyFromEnv — env-only, no interactive prompt > returns the trimmed key when the env var is set +(pass) readPresetKeyFromEnv — env-only, no interactive prompt > returns null when the env var is missing / empty +(pass) buildAuthJsonBody + writeOpencodeAuthJson > body shape matches opencode auth.json convention +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writes to /.local/share/opencode/auth.json with mode 0o600 [8.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writeOpencodeConfigJson lands under .config/opencode with 0o600 [7.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > keyless create atomically clears a private pre-planted auth file [10.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > default tool policy disables filesystem, shell, task, and skill tools +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writes only blessed provider identity and strips all pre-planted routing/executable config [7.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > atomically replaces a private but invalid pre-planted config without parsing it [7.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > rejects symlink escapes in workDir, config/data ancestors, and final targets [12.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > validates the full tree before mutation so a bad auth side cannot partially rewrite config [2.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > rejects permissive modes and foreign owners without chmod-follow repair [6.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > prepares .anet/nodes/node before profile secrets and provides atomic private leaf I/O [37.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > accepts an ordinary 0775 project root for a non-root uid=gid private group +(pass) buildAuthJsonBody + writeOpencodeAuthJson > profile preflight rejects .anet/nodes/node and config/.env symlink chains before secret writes [9.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > profile preflight rejects writable ancestors and non-private node roots [3.00ms] + +src/opencode-smoke-env.test.ts: +(pass) buildOpencodeSmokeEnv > locks the exact hardened ancestor candidate set [3.00ms] +(pass) buildOpencodeSmokeEnv > rejects sticky world-writable /tmp instead of silently degrading +(pass) buildOpencodeSmokeEnv > inherits only transport/locale trust settings and controls all OpenCode roots [3.00ms] +(pass) buildOpencodeSmokeEnv > every writable root can be precreated private, including XDG_RUNTIME_DIR [1.00ms] + +src/opencode-package-binary.test.ts: +(pass) validateOpencodePackageBinary > accepts only the canonical exact npm package entrypoint [3.00ms] +(pass) validateOpencodePackageBinary > rejects a same-version package impersonator inside the project [5.00ms] +(pass) validateOpencodePackageBinary > skips a same-version project shim and selects a later trusted package [4.00ms] +(pass) validateOpencodePackageBinary > rejects a monorepo-root package when invoked from a nested app [4.00ms] +(pass) validateOpencodePackageBinary > ordinary 0664 checkout package.json does not abort boundary discovery [3.00ms] +(pass) validateOpencodePackageBinary > accepts both exact registry spellings of bin.opencode [4.00ms] +(pass) validateOpencodePackageBinary > rejects forged name, version, and bin metadata [3.00ms] +(pass) validateOpencodePackageBinary > rejects world-writable files and package ancestors [6.00ms] +(pass) validateOpencodePackageBinary > rejects a symlinked package.json even when its contents are exact [2.00ms] + + 76 pass + 0 fail + 400 expect() calls +Ran 76 tests across 9 files. [1093.00ms] +PASS: exact pairing plus launch, TERM-only wrapper stop, auth-login, smoke-env, and preset security unit tests +bun test v1.3.1 (89fa0f34) + +src/runtime/opencode-acp/events.test.ts: +(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_message_chunk with text content → replyText += content.text [2.00ms] +(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_thought_chunk with text → thoughtText, NOT replyText (grok discipline) [1.00ms] +(pass) reduceOpencodeAcpNotification — session/update dispatch > tool_call and tool_call_update both bump toolCalls +(pass) reduceOpencodeAcpNotification — session/update dispatch > usage_update snaps totalTokens into state.usage +(pass) reduceOpencodeAcpNotification — session/update dispatch > available_commands_update consumed silently (session-init only) +(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_message_chunk without text content adds a warning +(pass) reduceOpencodeAcpNotification — session/update dispatch > unknown method returns ignored without mutating state +(pass) reduceOpencodeAcpNotification — session/update dispatch > unknown sessionUpdate subtype returns ignored (forward-compat) +(pass) reduceOpencodeAcpResponse — session/prompt terminal response > captures stopReason + usage from result +(pass) reduceOpencodeAcpResponse — session/prompt terminal response > missing stopReason still marks turn complete [1.00ms] +(pass) reduceOpencodeAcpFrames — replay the Phase 0b captured turn > full one-word turn: 10 thought chunks + 1 message chunk + usage + response [1.00ms] +(pass) reduceOpencodeAcpFrames — replay the Phase 0b captured turn > thinking-only terminal turn (no agent_message_chunk) — replyText stays empty + +src/runtime/opencode-acp/child-env.test.ts: +(pass) buildOpencodeChildEnv — deny-by-default boundary > locks the exact hardened ancestor candidate set +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects sticky world-writable /tmp instead of silently degrading [7.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > passes only runtime/network allowlist and controls all state roots [28.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > safe inline policy disables every local tool without replacing provider/model [21.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > unsafe opt-in explicitly overrides the wizard's persisted safe policy [10.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > detects exact managed config sources across Linux, Windows, and macOS [1.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > safe runtime renders ordinary same-uid config through a strict allowlist [20.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > copies only blessed API auth fields into fresh data and keeps persistent state outside the child [21.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > never exposes planted persistent DB/log/cache/state/tmp descendants in safe or unsafe mode [43.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > removes a partially built launch tree when env construction fails [18.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > pre-spawn revalidation hard-fails when an ancestor discovery candidate appears [29.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > keeps active roots but reclaims a dead-owner crash root without following symlinks [52.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > reclaims dead-owner roots after the node workDir is deleted or recreated [65.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > a transient cleanup pathname swap is retried after child exit [26.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > a dead owner marker is retained while an orphan child still references the root [48.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > durable process-group binding protects opaque descendants across later and crash-style sweeps [155.01ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > an exact exited-process identity exemption never hides a live descendant or PID mismatch [56.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects symlinks at workDir and every security-sensitive state layer [14.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects permissive modes and foreign ownership without repairing them [2.00ms] + +src/runtime/opencode-acp/profile-state.test.ts: +(pass) OpenCode private profile state > loads, atomically updates, backs up, and writes a session [13.00ms] +(pass) OpenCode private profile state > a post-load config symlink cannot redirect session writeback [1.00ms] +(pass) OpenCode private profile state > boot refuses a config symlink before self-heal can write its target +(pass) OpenCode private profile state > backup refuses a pre-planted .prev symlink [1.00ms] +(pass) OpenCode private profile state > runtime hint rejects suspicious config leaves for every runtime [1.00ms] + +src/runtime/opencode-acp/client.test.ts: +(pass) OpencodeAcpClient — request/response correlation > request() resolves with the matching response's result [163.01ms] +(pass) OpencodeAcpClient — request/response correlation > error response rejects the promise with a shaped message [150.01ms] +(pass) OpencodeAcpClient — streaming notifications > emits 'notification' for every session/update frame [144.01ms] +(pass) OpencodeAcpClient — streaming notifications > id-carrying reverse requests get an explicit method-not-found response [141.01ms] +(pass) OpencodeAcpClient — process lifecycle > supervisor receipt is ready before the vendor can inherit launch state [118.00ms] +(pass) OpencodeAcpClient — process lifecycle > child exit rejects all pending requests [147.01ms] +(pass) OpencodeAcpClient — process lifecycle > isRunning flips false after stop() [86.00ms] +(pass) OpencodeAcpClient — process lifecycle > a crashing group leader cannot leave a live descendant behind [203.01ms] +(pass) OpencodeAcpClient — process lifecycle > SIGSTOP supervisor makes stop fail closed until the exact owner resumes [348.01ms] +366 | // Test-only exact PID cleanup. Production deliberately retains and +367 | // reports this external-SIGKILL boundary instead of targeting old PGID. +368 | try { process.kill(vendorPid, "SIGKILL"); } catch {} +369 | try { process.kill(directVendorPid, "SIGKILL"); } catch {} +370 | await waitForFixture(() => !pidIsLive(vendorPid)); +371 | await waitForFixture(() => !pidIsLive(directVendorPid)); + ^ +ReferenceError: directVendorPid is not defined + at (/repo/agent-node/src/runtime/opencode-acp/client.test.ts:371:60) + at waitForFixture (/repo/agent-node/src/runtime/opencode-acp/client.test.ts:37:11) + at waitForFixture (/repo/agent-node/src/runtime/opencode-acp/client.test.ts:35:31) + at (/repo/agent-node/src/runtime/opencode-acp/client.test.ts:371:13) +(fail) OpencodeAcpClient — process lifecycle > external supervisor SIGKILL never triggers a stale PGID kill or clean exit [1617.07ms] +(pass) OpencodeAcpClient — process lifecycle > concurrent stop callers share one verified public exit [74.00ms] +(pass) OpencodeAcpClient — process lifecycle > explicit child env is not merged with the client's process.env [154.01ms] + +src/runtime/opencode-acp/runtime.test.ts: +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — cwd and tool policy > safe default keeps spawn + ACP session in one external launch workspace [222.01ms] +[opencode-acp] session/new — ses_probe_au... +(pass) openOpencodeRuntime — cwd and tool policy > version probe root is credential-free and gone before runtime auth is materialized [196.01ms] +[opencode-acp] session/load ok — resumed ses_existing... +(pass) openOpencodeRuntime — cwd and tool policy > safe session/load reuses the exact spawn PWD as its ACP cwd [227.01ms] +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — cwd and tool policy > explicit unsafe flag restores project cwd and emits a trusted-task warning [182.01ms] +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — opening lifecycle > normal stop removes the launch root and copied vendor auth [236.01ms] +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — opening lifecycle > repeated open/stop cycles do not accumulate launch roots [5788.24ms] +(pass) openOpencodeRuntime — opening lifecycle > an ancestor candidate planted by the version probe hard-fails before ACP spawn [91.00ms] +(pass) openOpencodeRuntime — opening lifecycle > package replacement after credential-free probe is rejected and runtime auth root is discarded [102.00ms] +(pass) openOpencodeRuntime — opening lifecycle > in-place binary self-modification after probe is rejected before credential spawn [110.00ms] +(pass) openOpencodeRuntime — opening lifecycle > production rejects canonical same-version packages below project cwd or node workDir [38.00ms] +(pass) openOpencodeRuntime — opening lifecycle > initialize failure force-kills the child before rejecting [187.01ms] +(pass) openOpencodeRuntime — opening lifecycle > onClient exposes a stalled-handshake child synchronously for shutdown [109.00ms] +[opencode-acp] session/new — ses_idle... +(pass) opencodeThink — failed-turn lifecycle > prompt idle timeout force-kills the child before rejecting [296.01ms] +[opencode-acp] session/new — ses_rescue_i... +[opencode-acp] #383 thinking-only terminal turn (chunks=0 thoughtChunks=1) — re-prompting for plain-text final +(pass) opencodeThink — failed-turn lifecycle > a failed thinking-only rescue discards the child before returning [245.01ms] + +src/runtime/opencode-acp/binary.test.ts: +(pass) resolvePinnedOpencodeBinary > locks the non-root uid=gid umask-0002 compatibility policy [3.00ms] +(pass) resolvePinnedOpencodeBinary > accepts the canonical package entrypoint and probes it from the external cwd [54.00ms] +(pass) resolvePinnedOpencodeBinary > accepts an npm-style PATH shim but returns the canonical package binary [40.00ms] +(pass) resolvePinnedOpencodeBinary > rejects a same-version fake package inside the project before executing it [1.00ms] +(pass) resolvePinnedOpencodeBinary > rejects forged package metadata and noncanonical entrypoints [4.00ms] +(pass) resolvePinnedOpencodeBinary > rejects unsafe file, package-directory, ancestor, and owner modes [5.00ms] +(pass) resolvePinnedOpencodeBinary > still enforces exact --version output after package identity succeeds [45.00ms] +(pass) resolvePinnedOpencodeBinary > refuses a caller-selected version other than the vetted release pin [1.00ms] +(pass) resolvePinnedOpencodeBinary > rejects a same-version package in a monorepo ancestor before probing it [3.00ms] +(pass) resolvePinnedOpencodeBinary > discovers a workspace ancestor when the configured project leaf is absent [1.00ms] +(pass) resolvePinnedOpencodeBinary > launcher absolute path wins over a hostile search PATH [26.00ms] +(pass) resolvePinnedOpencodeBinary > rejects non-absolute overrides + +1 tests failed: +(fail) OpencodeAcpClient — process lifecycle > external supervisor SIGKILL never triggers a stale PGID kill or clean exit [1617.07ms] + + 73 pass + 1 fail + 720 expect() calls +Ran 74 tests across 6 files. [12.32s] diff --git a/docs/tests/report-test386.txt b/docs/tests/report-test386.txt index 56953b55..7309c06b 100644 --- a/docs/tests/report-test386.txt +++ b/docs/tests/report-test386.txt @@ -1,105 +1,117 @@ # Test 386 — opencode-cli stale agent-node launch gate -- date: 2026-07-16T03:20:57+00:00 +- date: 2026-07-16T07:14:00+00:00 bun test v1.3.1 (89fa0f34) +src/opencode-wrapper-stop.test.ts: +(pass) bound OpenCode wrapper TERM-only stop > a responsive wrapper exits after SIGTERM [19.00ms] +(pass) bound OpenCode wrapper TERM-only stop > a parent-forwarded SIGINT retains a SIGSTOP'd wrapper and detached child [174.01ms] + src/opencode-owner-mode.test.ts: -(pass) OpenCode owner/mode policy > accepts umask-0002 modes only for a non-root uid=gid layout [1.00ms] +(pass) OpenCode owner/mode policy > accepts umask-0002 modes only for a non-root uid=gid layout [2.00ms] (pass) OpenCode owner/mode policy > always rejects world write and keeps root/foreign ownership strict src/opencode-agent-node-pair.test.ts: -(pass) OpenCode agent-node release pairing > pins the exact versions being released together [4.00ms] +(pass) OpenCode agent-node release pairing > pins the exact versions being released together (pass) OpenCode agent-node release pairing > rejects latest 2.4.x-style help and accepts the RFC-029 capability -(pass) OpenCode agent-node release pairing > admits only the exact preview package identity with safe file modes [5.00ms] -(pass) OpenCode agent-node release pairing > skips an exact project-local impersonator and selects the later global package [6.00ms] +(pass) OpenCode agent-node release pairing > admits only the exact preview package identity with safe file modes [10.00ms] +(pass) OpenCode agent-node release pairing > skips an exact project-local impersonator and selects the later global package [9.00ms] src/opencode-launch-env.test.ts: -(pass) hardenOpencodeAgentNodeEnv > restores launcher PATH and strips every pre-entrypoint loader hook [1.00ms] +(pass) hardenOpencodeAgentNodeEnv > restores launcher PATH and strips every pre-entrypoint loader hook [3.00ms] (pass) hardenOpencodeAgentNodeEnv > does not mutate the caller's env object (pass) hardenOpencodeAgentNodeEnv > strips case-variant loader and PATH keys for Windows semantics src/opencode-auth-login.test.ts: -(pass) OpenCode manual auth-login sandbox > builds deterministic provider-specific API-key login argv -(pass) OpenCode manual auth-login sandbox > uses a fresh all-XDG tree and strips ambient credentials/config hooks [39.00ms] -(pass) OpenCode manual auth-login sandbox > strictly consumes only the selected provider API record through a private leaf [29.00ms] -(pass) OpenCode manual auth-login sandbox > refuses OAuth, mixed-provider and symlink auth shapes without disclosing secrets [21.00ms] -(pass) OpenCode manual auth-login sandbox > persistent planted DB/log links are never exposed and cleanup never follows descendant links [37.00ms] -(pass) OpenCode manual auth-login sandbox > cleanup unlinks a swapped root symlink but never removes its outside target [18.00ms] -(pass) OpenCode manual auth-login sandbox > cleanup quarantines the tracked inode but leaves a regular root-name replacement untouched [18.00ms] -(pass) OpenCode manual auth-login sandbox > a live tracked root whose literal name ends in deleted is still removed [24.00ms] -(pass) OpenCode manual auth-login sandbox > Linux reports nlink zero for a removed directory retained by fd [11.00ms] -(pass) OpenCode manual auth-login sandbox > cleanup retains inode ownership after bounded failure and succeeds on retry [24.00ms] -(pass) OpenCode manual auth-login sandbox > refuses a concurrent live owner marker [28.00ms] -(pass) OpenCode manual auth-login sandbox > refuses a provider that does not match the node's unique configured preset [13.00ms] -(pass) OpenCode manual auth-login sandbox > prunes a dead owner's stale root without following its planted links [47.00ms] -(pass) OpenCode manual auth-login sandbox > PID reuse does not retain a stale credential root [51.00ms] -(pass) OpenCode manual auth-login sandbox > stale sweep resumes a crash-left quarantine while its owner marker remains [41.00ms] -(pass) OpenCode manual auth-login sandbox > stale sweep removes an empty quarantine left after marker-last deletion [27.00ms] -(pass) OpenCode manual auth-login sandbox > spawn-time revalidation rejects a hostile ancestor discovery candidate [24.00ms] -(pass) OpenCode manual auth-login sandbox > with helper always cleans the fresh root when the action throws [39.00ms] +(pass) OpenCode manual auth-login sandbox > builds deterministic provider-specific API-key login argv [1.00ms] +(pass) OpenCode manual auth-login sandbox > uses a fresh all-XDG tree and strips ambient credentials/config hooks [35.00ms] +(pass) OpenCode manual auth-login sandbox > strictly consumes only the selected provider API record through a private leaf [24.00ms] +(pass) OpenCode manual auth-login sandbox > refuses OAuth, mixed-provider and symlink auth shapes without disclosing secrets [22.00ms] +(pass) OpenCode manual auth-login sandbox > persistent planted DB/log links are never exposed and cleanup never follows descendant links [24.00ms] +(pass) OpenCode manual auth-login sandbox > cleanup unlinks a swapped root symlink but never removes its outside target [20.00ms] +(pass) OpenCode manual auth-login sandbox > cleanup quarantines the tracked inode but leaves a regular root-name replacement untouched [22.00ms] +(pass) OpenCode manual auth-login sandbox > a live tracked root whose literal name ends in deleted is still removed [20.00ms] +(pass) OpenCode manual auth-login sandbox > Linux reports nlink zero for a removed directory retained by fd [9.00ms] +(pass) OpenCode manual auth-login sandbox > cleanup retains inode ownership after bounded failure and succeeds on retry [28.00ms] +(pass) OpenCode manual auth-login sandbox > refuses a concurrent live owner marker [24.00ms] +(pass) OpenCode manual auth-login sandbox > refuses a provider that does not match the node's unique configured preset [16.00ms] +(pass) OpenCode manual auth-login sandbox > prunes a dead owner's stale root without following its planted links [61.00ms] +(pass) OpenCode manual auth-login sandbox > PID reuse does not retain a stale credential root [48.00ms] +(pass) OpenCode manual auth-login sandbox > stale sweep resumes a crash-left quarantine while its owner marker remains [33.00ms] +(pass) OpenCode manual auth-login sandbox > stale sweep removes an empty quarantine left after marker-last deletion [22.00ms] +(pass) OpenCode manual auth-login sandbox > spawn-time revalidation rejects a hostile ancestor discovery candidate [27.00ms] +(pass) OpenCode manual auth-login sandbox > with helper always cleans the fresh root when the action throws [25.00ms] src/opencode-runtime-binding.test.ts: -(pass) external OpenCode runtime binding > survives regular config runtime downgrade and proves the original exact runtime [16.00ms] -(pass) external OpenCode runtime binding > read returns undefined only for absent state and deterministic keys separate nodes [9.00ms] -(pass) external OpenCode runtime binding > rejects binding-directory and leaf symlinks [10.00ms] -(pass) external OpenCode runtime binding > rejects permissive modes, hard links, and foreign ownership [7.00ms] -(pass) external OpenCode runtime binding > rejects private but tampered runtime, identity, and extra fields [8.00ms] -(pass) assertOpencodeNodeStateUntracked > allows ordinary non-Git projects [2.00ms] -(pass) assertOpencodeNodeStateUntracked > allows ignored/untracked state but rejects git add -f tracked state [39.00ms] -(pass) assertOpencodeNodeStateUntracked > rejects a force-added dotenv or any tracked file below the node directory [54.00ms] +(pass) external OpenCode runtime binding > survives regular config runtime downgrade and proves the original exact runtime [11.00ms] +(pass) external OpenCode runtime binding > read returns undefined only for absent state and deterministic keys separate nodes [10.00ms] +(pass) external OpenCode runtime binding > an absent exact leaf does not impose POSIX modes on ordinary runtime state [2.00ms] +(pass) external OpenCode runtime binding > unbound legacy symlink or junction-style node paths remain invisible [7.00ms] +(pass) external OpenCode runtime binding > Windows synthetic permission bits do not disable structural security checks [1.00ms] +(pass) external OpenCode runtime binding > secure removal is idempotent and removes the exact binding [12.00ms] +(pass) external OpenCode runtime binding > secure removal refuses tampered content without unlinking it [6.00ms] +(pass) external OpenCode runtime binding > rejects binding-directory and leaf symlinks [6.00ms] +(pass) external OpenCode runtime binding > rejects dangling binding-root and exact-leaf symlinks [2.00ms] +(pass) external OpenCode runtime binding > rejects permissive modes, hard links, and foreign ownership [8.00ms] +(pass) external OpenCode runtime binding > rejects private but tampered runtime, identity, and extra fields [7.00ms] +(pass) external OpenCode runtime binding > rejects binding roots that overlap the canonical project in either direction [5.00ms] +(pass) external OpenCode runtime binding > a symlinked node workDir cannot remove another project's binding [6.00ms] +(pass) assertOpencodeNodeStateUntracked > allows ordinary non-Git projects [1.00ms] +(pass) assertOpencodeNodeStateUntracked > allows ignored/untracked state but rejects git add -f tracked state [21.00ms] +(pass) assertOpencodeNodeStateUntracked > rejects a force-added dotenv or any tracked file below the node directory [23.00ms] src/opencode-preset.test.ts: -(pass) OPENCODE_PRESETS registry > exports the two blessed presets (anthropic + openai) [1.00ms] +(pass) OPENCODE_PRESETS registry > exports the two blessed presets (anthropic + openai) (pass) OPENCODE_PRESETS registry > findOpencodePreset('anthropic') returns the record; unknown returns null (pass) readPresetKeyFromEnv — env-only, no interactive prompt > returns the trimmed key when the env var is set (pass) readPresetKeyFromEnv — env-only, no interactive prompt > returns null when the env var is missing / empty (pass) buildAuthJsonBody + writeOpencodeAuthJson > body shape matches opencode auth.json convention -(pass) buildAuthJsonBody + writeOpencodeAuthJson > writes to /.local/share/opencode/auth.json with mode 0o600 [7.00ms] -(pass) buildAuthJsonBody + writeOpencodeAuthJson > writeOpencodeConfigJson lands under .config/opencode with 0o600 [8.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writes to /.local/share/opencode/auth.json with mode 0o600 [6.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writeOpencodeConfigJson lands under .config/opencode with 0o600 [7.00ms] (pass) buildAuthJsonBody + writeOpencodeAuthJson > keyless create atomically clears a private pre-planted auth file [6.00ms] (pass) buildAuthJsonBody + writeOpencodeAuthJson > default tool policy disables filesystem, shell, task, and skill tools [1.00ms] -(pass) buildAuthJsonBody + writeOpencodeAuthJson > writes only blessed provider identity and strips all pre-planted routing/executable config [7.00ms] -(pass) buildAuthJsonBody + writeOpencodeAuthJson > atomically replaces a private but invalid pre-planted config without parsing it [8.00ms] -(pass) buildAuthJsonBody + writeOpencodeAuthJson > rejects symlink escapes in workDir, config/data ancestors, and final targets [13.00ms] -(pass) buildAuthJsonBody + writeOpencodeAuthJson > validates the full tree before mutation so a bad auth side cannot partially rewrite config [1.00ms] -(pass) buildAuthJsonBody + writeOpencodeAuthJson > rejects permissive modes and foreign owners without chmod-follow repair [7.00ms] -(pass) buildAuthJsonBody + writeOpencodeAuthJson > prepares .anet/nodes/node before profile secrets and provides atomic private leaf I/O [31.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writes only blessed provider identity and strips all pre-planted routing/executable config [9.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > atomically replaces a private but invalid pre-planted config without parsing it [9.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > rejects symlink escapes in workDir, config/data ancestors, and final targets [12.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > validates the full tree before mutation so a bad auth side cannot partially rewrite config [2.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > rejects permissive modes and foreign owners without chmod-follow repair [5.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > prepares .anet/nodes/node before profile secrets and provides atomic private leaf I/O [28.00ms] (pass) buildAuthJsonBody + writeOpencodeAuthJson > accepts an ordinary 0775 project root for a non-root uid=gid private group [1.00ms] -(pass) buildAuthJsonBody + writeOpencodeAuthJson > profile preflight rejects .anet/nodes/node and config/.env symlink chains before secret writes [8.00ms] -(pass) buildAuthJsonBody + writeOpencodeAuthJson > profile preflight rejects writable ancestors and non-private node roots [2.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > profile preflight rejects .anet/nodes/node and config/.env symlink chains before secret writes [6.00ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > profile preflight rejects writable ancestors and non-private node roots [3.00ms] src/opencode-smoke-env.test.ts: -(pass) buildOpencodeSmokeEnv > locks the exact hardened ancestor candidate set [2.00ms] -(pass) buildOpencodeSmokeEnv > rejects sticky world-writable /tmp instead of silently degrading +(pass) buildOpencodeSmokeEnv > locks the exact hardened ancestor candidate set +(pass) buildOpencodeSmokeEnv > rejects sticky world-writable /tmp instead of silently degrading [1.00ms] (pass) buildOpencodeSmokeEnv > inherits only transport/locale trust settings and controls all OpenCode roots [1.00ms] (pass) buildOpencodeSmokeEnv > every writable root can be precreated private, including XDG_RUNTIME_DIR [1.00ms] src/opencode-package-binary.test.ts: -(pass) validateOpencodePackageBinary > accepts only the canonical exact npm package entrypoint [3.00ms] -(pass) validateOpencodePackageBinary > rejects a same-version package impersonator inside the project [1.00ms] -(pass) validateOpencodePackageBinary > skips a same-version project shim and selects a later trusted package [3.00ms] +(pass) validateOpencodePackageBinary > accepts only the canonical exact npm package entrypoint [2.00ms] +(pass) validateOpencodePackageBinary > rejects a same-version package impersonator inside the project [3.00ms] +(pass) validateOpencodePackageBinary > skips a same-version project shim and selects a later trusted package [2.00ms] (pass) validateOpencodePackageBinary > rejects a monorepo-root package when invoked from a nested app [3.00ms] -(pass) validateOpencodePackageBinary > ordinary 0664 checkout package.json does not abort boundary discovery [3.00ms] -(pass) validateOpencodePackageBinary > accepts both exact registry spellings of bin.opencode [2.00ms] +(pass) validateOpencodePackageBinary > ordinary 0664 checkout package.json does not abort boundary discovery [1.00ms] +(pass) validateOpencodePackageBinary > accepts both exact registry spellings of bin.opencode [1.00ms] (pass) validateOpencodePackageBinary > rejects forged name, version, and bin metadata [2.00ms] -(pass) validateOpencodePackageBinary > rejects writable files and writable package ancestors [3.00ms] -(pass) validateOpencodePackageBinary > rejects a symlinked package.json even when its contents are exact +(pass) validateOpencodePackageBinary > rejects world-writable files and package ancestors [1.00ms] +(pass) validateOpencodePackageBinary > rejects a symlinked package.json even when its contents are exact [1.00ms] - 66 pass + 76 pass 0 fail - 342 expect() calls -Ran 66 tests across 8 files. [900.00ms] -PASS: exact pairing plus launch, auth-login, smoke-env, and preset security unit tests + 400 expect() calls +Ran 76 tests across 9 files. [1191.00ms] +PASS: exact pairing plus launch, TERM-only wrapper stop, auth-login, smoke-env, and preset security unit tests bun test v1.3.1 (89fa0f34) src/runtime/opencode-acp/events.test.ts: -(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_message_chunk with text content → replyText += content.text +(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_message_chunk with text content → replyText += content.text [1.00ms] (pass) reduceOpencodeAcpNotification — session/update dispatch > agent_thought_chunk with text → thoughtText, NOT replyText (grok discipline) (pass) reduceOpencodeAcpNotification — session/update dispatch > tool_call and tool_call_update both bump toolCalls (pass) reduceOpencodeAcpNotification — session/update dispatch > usage_update snaps totalTokens into state.usage -(pass) reduceOpencodeAcpNotification — session/update dispatch > available_commands_update consumed silently (session-init only) [1.00ms] +(pass) reduceOpencodeAcpNotification — session/update dispatch > available_commands_update consumed silently (session-init only) (pass) reduceOpencodeAcpNotification — session/update dispatch > agent_message_chunk without text content adds a warning -(pass) reduceOpencodeAcpNotification — session/update dispatch > unknown method returns ignored without mutating state +(pass) reduceOpencodeAcpNotification — session/update dispatch > unknown method returns ignored without mutating state [1.00ms] (pass) reduceOpencodeAcpNotification — session/update dispatch > unknown sessionUpdate subtype returns ignored (forward-compat) (pass) reduceOpencodeAcpResponse — session/prompt terminal response > captures stopReason + usage from result (pass) reduceOpencodeAcpResponse — session/prompt terminal response > missing stopReason still marks turn complete @@ -108,46 +120,57 @@ src/runtime/opencode-acp/events.test.ts: src/runtime/opencode-acp/child-env.test.ts: (pass) buildOpencodeChildEnv — deny-by-default boundary > locks the exact hardened ancestor candidate set -(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects sticky world-writable /tmp instead of silently degrading [8.00ms] -(pass) buildOpencodeChildEnv — deny-by-default boundary > passes only runtime/network allowlist and controls all state roots [22.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects sticky world-writable /tmp instead of silently degrading [10.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > passes only runtime/network allowlist and controls all state roots [21.00ms] (pass) buildOpencodeChildEnv — deny-by-default boundary > safe inline policy disables every local tool without replacing provider/model [13.00ms] -(pass) buildOpencodeChildEnv — deny-by-default boundary > unsafe opt-in explicitly overrides the wizard's persisted safe policy [10.00ms] -(pass) buildOpencodeChildEnv — deny-by-default boundary > safe runtime renders ordinary same-uid config through a strict allowlist [17.00ms] -(pass) buildOpencodeChildEnv — deny-by-default boundary > copies only blessed API auth fields into fresh data and keeps persistent state outside the child [22.00ms] -(pass) buildOpencodeChildEnv — deny-by-default boundary > never exposes planted persistent DB/log/cache/state/tmp descendants in safe or unsafe mode [30.00ms] -(pass) buildOpencodeChildEnv — deny-by-default boundary > removes a partially built launch tree when env construction fails [19.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > unsafe opt-in explicitly overrides the wizard's persisted safe policy [9.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > detects exact managed config sources across Linux, Windows, and macOS [1.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > safe runtime renders ordinary same-uid config through a strict allowlist [16.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > copies only blessed API auth fields into fresh data and keeps persistent state outside the child [17.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > never exposes planted persistent DB/log/cache/state/tmp descendants in safe or unsafe mode [25.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > removes a partially built launch tree when env construction fails [18.00ms] (pass) buildOpencodeChildEnv — deny-by-default boundary > pre-spawn revalidation hard-fails when an ancestor discovery candidate appears [19.00ms] -(pass) buildOpencodeChildEnv — deny-by-default boundary > keeps active roots but reclaims a dead-owner crash root without following symlinks [47.00ms] -(pass) buildOpencodeChildEnv — deny-by-default boundary > a transient cleanup pathname swap is retried after child exit [29.00ms] -(pass) buildOpencodeChildEnv — deny-by-default boundary > a dead owner marker is retained while an orphan child still references the root [64.00ms] -(pass) buildOpencodeChildEnv — deny-by-default boundary > an exact exited-process identity exemption never hides a live descendant or PID mismatch [80.00ms] -(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects symlinks at workDir and every security-sensitive state layer [19.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > keeps active roots but reclaims a dead-owner crash root without following symlinks [46.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > reclaims dead-owner roots after the node workDir is deleted or recreated [62.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > a transient cleanup pathname swap is retried after child exit [32.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > a dead owner marker is retained while an orphan child still references the root [82.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > durable process-group binding protects opaque descendants across later and crash-style sweeps [147.01ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > an exact exited-process identity exemption never hides a live descendant or PID mismatch [81.00ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects symlinks at workDir and every security-sensitive state layer [15.00ms] (pass) buildOpencodeChildEnv — deny-by-default boundary > rejects permissive modes and foreign ownership without repairing them [2.00ms] src/runtime/opencode-acp/profile-state.test.ts: -(pass) OpenCode private profile state > loads, atomically updates, backs up, and writes a session [15.00ms] -(pass) OpenCode private profile state > a post-load config symlink cannot redirect session writeback [2.00ms] +(pass) OpenCode private profile state > loads, atomically updates, backs up, and writes a session [14.00ms] +(pass) OpenCode private profile state > a post-load config symlink cannot redirect session writeback [1.00ms] (pass) OpenCode private profile state > boot refuses a config symlink before self-heal can write its target [1.00ms] -(pass) OpenCode private profile state > backup refuses a pre-planted .prev symlink +(pass) OpenCode private profile state > backup refuses a pre-planted .prev symlink [1.00ms] (pass) OpenCode private profile state > runtime hint rejects suspicious config leaves for every runtime [1.00ms] src/runtime/opencode-acp/client.test.ts: -(pass) OpencodeAcpClient — request/response correlation > request() resolves with the matching response's result [102.00ms] -(pass) OpencodeAcpClient — request/response correlation > error response rejects the promise with a shaped message [73.00ms] -(pass) OpencodeAcpClient — streaming notifications > emits 'notification' for every session/update frame [78.00ms] -(pass) OpencodeAcpClient — process lifecycle > child exit rejects all pending requests [78.00ms] -(pass) OpencodeAcpClient — process lifecycle > isRunning flips false after stop() [10.00ms] -(pass) OpencodeAcpClient — process lifecycle > explicit child env is not merged with the client's process.env [72.00ms] +(pass) OpencodeAcpClient — request/response correlation > request() resolves with the matching response's result [150.01ms] +(pass) OpencodeAcpClient — request/response correlation > error response rejects the promise with a shaped message [173.01ms] +(pass) OpencodeAcpClient — streaming notifications > emits 'notification' for every session/update frame [152.01ms] +(pass) OpencodeAcpClient — streaming notifications > id-carrying reverse requests get an explicit method-not-found response [180.01ms] +(pass) OpencodeAcpClient — process lifecycle > supervisor receipt is ready before the vendor can inherit launch state [143.01ms] +(pass) OpencodeAcpClient — process lifecycle > child exit rejects all pending requests [169.01ms] +(pass) OpencodeAcpClient — process lifecycle > isRunning flips false after stop() [95.00ms] +(pass) OpencodeAcpClient — process lifecycle > a crashing group leader cannot leave a live descendant behind [212.01ms] +(pass) OpencodeAcpClient — process lifecycle > SIGSTOP supervisor makes stop fail closed until the exact owner resumes [346.01ms] +(pass) OpencodeAcpClient — process lifecycle > external supervisor SIGKILL never triggers a stale PGID kill or clean exit [1665.07ms] +(pass) OpencodeAcpClient — process lifecycle > concurrent stop callers share one verified public exit [72.00ms] +(pass) OpencodeAcpClient — process lifecycle > explicit child env is not merged with the client's process.env [129.01ms] src/runtime/opencode-acp/runtime.test.ts: [opencode-acp] session/new — ses_test... -(pass) openOpencodeRuntime — cwd and tool policy > safe default keeps spawn + ACP session in one external launch workspace [168.01ms] +(pass) openOpencodeRuntime — cwd and tool policy > safe default keeps spawn + ACP session in one external launch workspace [255.01ms] +[opencode-acp] session/new — ses_probe_au... +(pass) openOpencodeRuntime — cwd and tool policy > version probe root is credential-free and gone before runtime auth is materialized [224.01ms] [opencode-acp] session/load ok — resumed ses_existing... -(pass) openOpencodeRuntime — cwd and tool policy > safe session/load reuses the exact spawn PWD as its ACP cwd [143.01ms] +(pass) openOpencodeRuntime — cwd and tool policy > safe session/load reuses the exact spawn PWD as its ACP cwd [246.01ms] [opencode-acp] session/new — ses_test... -(pass) openOpencodeRuntime — cwd and tool policy > explicit unsafe flag restores project cwd and emits a trusted-task warning [117.00ms] +(pass) openOpencodeRuntime — cwd and tool policy > explicit unsafe flag restores project cwd and emits a trusted-task warning [239.01ms] [opencode-acp] session/new — ses_test... -(pass) openOpencodeRuntime — opening lifecycle > normal stop removes the launch root and copied vendor auth [141.01ms] +(pass) openOpencodeRuntime — opening lifecycle > normal stop removes the launch root and copied vendor auth [242.01ms] [opencode-acp] session/new — ses_test... [opencode-acp] session/new — ses_test... [opencode-acp] session/new — ses_test... @@ -173,52 +196,56 @@ src/runtime/opencode-acp/runtime.test.ts: [opencode-acp] session/new — ses_test... [opencode-acp] session/new — ses_test... [opencode-acp] session/new — ses_test... -(pass) openOpencodeRuntime — opening lifecycle > repeated open/stop cycles do not accumulate launch roots [3290.14ms] -(pass) openOpencodeRuntime — opening lifecycle > an ancestor candidate planted by the version probe hard-fails before ACP spawn [73.00ms] -(pass) openOpencodeRuntime — opening lifecycle > production rejects canonical same-version packages below project cwd or node workDir [27.00ms] -(pass) openOpencodeRuntime — opening lifecycle > initialize failure force-kills the child before rejecting [126.01ms] -(pass) openOpencodeRuntime — opening lifecycle > onClient exposes a stalled-handshake child synchronously for shutdown [54.00ms] +(pass) openOpencodeRuntime — opening lifecycle > repeated open/stop cycles do not accumulate launch roots [6200.26ms] +(pass) openOpencodeRuntime — opening lifecycle > an ancestor candidate planted by the version probe hard-fails before ACP spawn [84.00ms] +(pass) openOpencodeRuntime — opening lifecycle > package replacement after credential-free probe is rejected and runtime auth root is discarded [93.00ms] +(pass) openOpencodeRuntime — opening lifecycle > in-place binary self-modification after probe is rejected before credential spawn [90.00ms] +(pass) openOpencodeRuntime — opening lifecycle > production rejects canonical same-version packages below project cwd or node workDir [32.00ms] +(pass) openOpencodeRuntime — opening lifecycle > initialize failure force-kills the child before rejecting [242.01ms] +(pass) openOpencodeRuntime — opening lifecycle > onClient exposes a stalled-handshake child synchronously for shutdown [125.01ms] [opencode-acp] session/new — ses_idle... -(pass) opencodeThink — failed-turn lifecycle > prompt idle timeout force-kills the child before rejecting [181.01ms] +(pass) opencodeThink — failed-turn lifecycle > prompt idle timeout force-kills the child before rejecting [323.01ms] [opencode-acp] session/new — ses_rescue_i... [opencode-acp] #383 thinking-only terminal turn (chunks=0 thoughtChunks=1) — re-prompting for plain-text final -(pass) opencodeThink — failed-turn lifecycle > a failed thinking-only rescue discards the child before returning [173.01ms] +(pass) opencodeThink — failed-turn lifecycle > a failed thinking-only rescue discards the child before returning [258.01ms] src/runtime/opencode-acp/binary.test.ts: -(pass) resolvePinnedOpencodeBinary > locks the non-root uid=gid umask-0002 compatibility policy [1.00ms] -(pass) resolvePinnedOpencodeBinary > accepts the canonical package entrypoint and probes it from the external cwd [50.00ms] -(pass) resolvePinnedOpencodeBinary > accepts an npm-style PATH shim but returns the canonical package binary [26.00ms] +(pass) resolvePinnedOpencodeBinary > locks the non-root uid=gid umask-0002 compatibility policy +(pass) resolvePinnedOpencodeBinary > accepts the canonical package entrypoint and probes it from the external cwd [45.00ms] +(pass) resolvePinnedOpencodeBinary > accepts an npm-style PATH shim but returns the canonical package binary [29.00ms] (pass) resolvePinnedOpencodeBinary > rejects a same-version fake package inside the project before executing it [1.00ms] (pass) resolvePinnedOpencodeBinary > rejects forged package metadata and noncanonical entrypoints [3.00ms] -(pass) resolvePinnedOpencodeBinary > rejects unsafe file, package-directory, ancestor, and owner modes [3.00ms] -(pass) resolvePinnedOpencodeBinary > still enforces exact --version output after package identity succeeds [26.00ms] -(pass) resolvePinnedOpencodeBinary > refuses a caller-selected version other than the vetted release pin [1.00ms] -(pass) resolvePinnedOpencodeBinary > rejects a same-version package in a monorepo ancestor before probing it [1.00ms] +(pass) resolvePinnedOpencodeBinary > rejects unsafe file, package-directory, ancestor, and owner modes [5.00ms] +(pass) resolvePinnedOpencodeBinary > still enforces exact --version output after package identity succeeds [33.00ms] +(pass) resolvePinnedOpencodeBinary > refuses a caller-selected version other than the vetted release pin [8.00ms] +(pass) resolvePinnedOpencodeBinary > rejects a same-version package in a monorepo ancestor before probing it [2.00ms] (pass) resolvePinnedOpencodeBinary > discovers a workspace ancestor when the configured project leaf is absent [1.00ms] -(pass) resolvePinnedOpencodeBinary > launcher absolute path wins over a hostile search PATH [27.00ms] +(pass) resolvePinnedOpencodeBinary > launcher absolute path wins over a hostile search PATH [32.00ms] (pass) resolvePinnedOpencodeBinary > rejects non-absolute overrides [1.00ms] - 62 pass + 74 pass 0 fail - 636 expect() calls -Ran 62 tests across 6 files. [5.63s] + 722 expect() calls +Ran 74 tests across 6 files. [13.07s] PASS: agent-node OpenCode package, child-env, profile-state, ACP client/events, and runtime unit tests +Error: opencode safe mode refuses OS-managed config source /etc/opencode/opencode.json; managed MCP/tools load after inline config +PASS: safe runtime rejects exact /etc/opencode managed source before hostile MCP/version spawn $ tsc --noEmit PASS: agent-network typecheck $ bun build src/client.ts --outdir dist/src --target node --minify && bun build bin/cli.ts --outdir dist/bin --target node --minify --external @sleep2agi/commhub-server --external bun:sqlite --external '../../server/*' && bun build src/node-server.ts --outdir dist/src --target node --minify --external @modelcontextprotocol/sdk && bun build src/im/feishu/worker.ts --outdir dist/src/im/feishu --target node --minify --external @larksuiteoapi/node-sdk && tsc --emitDeclarationOnly --declaration --outDir dist && bun x javascript-obfuscator dist/bin/cli.js --output dist/bin/cli.js --compact true --string-array true --string-array-encoding base64 && bun x javascript-obfuscator dist/src/client.js --output dist/src/client.js --compact true --string-array true && bun x javascript-obfuscator dist/src/node-server.js --output dist/src/node-server.js --compact true --string-array true -Bundled 1 module in 8ms +Bundled 1 module in 14ms client.js 3.67 KB (entry point) -Bundled 93 modules in 70ms +Bundled 94 modules in 80ms - cli.js 0.65 MB (entry point) + cli.js 0.67 MB (entry point) -Bundled 2 modules in 6ms +Bundled 2 modules in 7ms - node-server.js 10.56 KB (entry point) + node-server.js 11.15 KB (entry point) -Bundled 597 modules in 151ms +Bundled 597 modules in 195ms worker.js 2.29 MB (entry point) @@ -229,6 +256,24 @@ Bundled 597 modules in 151ms [javascript-obfuscator-cli] Obfuscating file: dist/src/node-server.js... PASS: agent-network release bundle build +[anet] Stopped "renamed-after-launch" (process killed, server notified) +[anet] bound OpenCode tmux session "renamed-after-launch" was not mutated; inspect it explicitly +PASS: bound stop ignores mutable alias, verifies exact wrapper exit, and retains same-name tmux/binding + +[anet] anet project up — 1 node(s) in /tmp/test386-bound-wedged/work + ⚠ wedged-gate — invalid config: bound opencode-cli nodes require explicit 'anet node start/stop' lifecycle + +────────────────────────────────────────────── + 0/1 up · 1 invalid + Invalid config (not started): + ⚠ wedged-gate — bound opencode-cli nodes require explicit 'anet node start/stop' lifecycle + +[anet] refusing batch stop: 1 bound opencode-cli node(s) require explicit node stop +[anet] refusing delete of bound opencode-cli node without an external lifecycle lock; run 'anet node stop' and retain the node for explicit inspection +[anet] Refusing to rename running bound OpenCode node "wedged-gate": exact wrapper pid(s) 1709 still live; run 'anet node stop wedged-gate' first. +PASS: project up, batch stop, delete, and running rename preserve bound OpenCode ownership state +[anet] refusing/failed authoritative stop: bound OpenCode process 1709 did not exit after SIGTERM; wrapper retained so its detached ACP process group is not orphaned +PASS: SIGSTOP stop fails closed and retains wrapper, detached child, pid file, config, and binding PASS network validator: /home/bun/prefix/node_modules/opencode-ai/bin/opencode.exe PASS agent-node validator + version probe: /home/bun/prefix/node_modules/opencode-ai/bin/opencode.exe PASS private uid=gid group-write paths: 6 @@ -239,38 +284,38 @@ PASS: non-root uid=gid=1000 umask-0002 real opencode-ai@1.18.1 passes network an PASS: failed upstream auth-login exits nonzero, preserves old auth, and cleans its disposable root [anet] Starting new session for "gate" [opencode-cli]... -[anet] using installed exact @sleep2agi/agent-node@2.5.0-preview.26. +[anet] using installed exact @sleep2agi/agent-node@2.5.0-preview.27. [anet] Token: [REDACTED_TOKEN] PASS: stale global bypassed; later exact global received protected PATH/binary/version/base; npx was not executed [anet] Starting new session for "gate" [opencode-cli]... -[anet] using installed exact @sleep2agi/agent-node@2.5.0-preview.26. +[anet] using installed exact @sleep2agi/agent-node@2.5.0-preview.27. [anet] Token: [REDACTED_TOKEN] PASS: same-version project-local OpenCode/agent-node payloads skipped; canonical external packages launched [anet] Starting new session for "gate" [opencode-cli]... [anet] Incompatible agent-node for opencode-cli. -[anet] ANET_AGENT_NODE_BIN is not the exact trusted @sleep2agi/agent-node@2.5.0-preview.26: project/node-local agent-node package payload is not trusted -Install the exact vetted pair: npm install -g @sleep2agi/agent-network@2.3.0-preview.34 @sleep2agi/agent-node@2.5.0-preview.26 +[anet] ANET_AGENT_NODE_BIN is not the exact trusted @sleep2agi/agent-node@2.5.0-preview.27: project/node-local agent-node package payload is not trusted +Install the exact vetted pair: npm install -g @sleep2agi/agent-network@2.3.0-preview.35 @sleep2agi/agent-node@2.5.0-preview.27 [anet] Refusing to start: an unsupported agent-node could silently select another runtime. PASS: explicit exact project-local agent-node is rejected before execution and without npx [anet] Starting new session for "gate" [opencode-cli]... -[anet] using installed exact @sleep2agi/agent-node@2.5.0-preview.26. +[anet] using installed exact @sleep2agi/agent-node@2.5.0-preview.27. [anet] Token: [REDACTED_TOKEN] -PASS: capable-looking global preview.21 rejected; later exact global preview.26 launched without npx +PASS: capable-looking global preview.21 rejected; later exact global preview.27 launched without npx [anet] Starting new session for "gate" [opencode-cli]... [anet] Incompatible agent-node for opencode-cli. -[anet] ANET_AGENT_NODE_BIN is not the exact trusted @sleep2agi/agent-node@2.5.0-preview.26: resolved agent-node package is not exact version 2.5.0-preview.26 -Install the exact vetted pair: npm install -g @sleep2agi/agent-network@2.3.0-preview.34 @sleep2agi/agent-node@2.5.0-preview.26 +[anet] ANET_AGENT_NODE_BIN is not the exact trusted @sleep2agi/agent-node@2.5.0-preview.27: resolved agent-node package is not exact version 2.5.0-preview.27 +Install the exact vetted pair: npm install -g @sleep2agi/agent-network@2.3.0-preview.35 @sleep2agi/agent-node@2.5.0-preview.27 [anet] Refusing to start: an unsupported agent-node could silently select another runtime. PASS: ANET_AGENT_NODE_BIN cannot bypass the exact hardened pair [anet] Starting new session for "gate" [opencode-cli]... [anet] Incompatible agent-node for opencode-cli. -[anet] No exact trusted global @sleep2agi/agent-node@2.5.0-preview.26 is available (no exact trusted @sleep2agi/agent-node@2.5.0-preview.26 package entrypoint found on PATH; first rejected candidate: @sleep2agi/agent-node@2.5.0-preview.26 entrypoint is outside node_modules/@sleep2agi/agent-node); automatic npx execution is disabled for opencode-cli -Install the exact vetted pair: npm install -g @sleep2agi/agent-network@2.3.0-preview.34 @sleep2agi/agent-node@2.5.0-preview.26 +[anet] No exact trusted global @sleep2agi/agent-node@2.5.0-preview.27 is available (no exact trusted @sleep2agi/agent-node@2.5.0-preview.27 package entrypoint found on PATH; first rejected candidate: @sleep2agi/agent-node@2.5.0-preview.27 entrypoint is outside node_modules/@sleep2agi/agent-node); automatic npx execution is disabled for opencode-cli +Install the exact vetted pair: npm install -g @sleep2agi/agent-network@2.3.0-preview.35 @sleep2agi/agent-node@2.5.0-preview.27 [anet] Refusing to start: an unsupported agent-node could silently select another runtime. PASS: no exact global rejects startup, executes no npx, and prints exact dual-package remediation diff --git a/tests/test384-opencode-local-package-e2e/Dockerfile b/tests/test384-opencode-local-package-e2e/Dockerfile index 28722651..b5bad1d5 100644 --- a/tests/test384-opencode-local-package-e2e/Dockerfile +++ b/tests/test384-opencode-local-package-e2e/Dockerfile @@ -1,8 +1,8 @@ FROM node:22-bookworm-slim ARG OPENCODE_VERSION=1.18.1 -ARG AGENT_NETWORK_VERSION=2.3.0-preview.34 -ARG AGENT_NODE_VERSION=2.5.0-preview.26 +ARG AGENT_NETWORK_VERSION=2.3.0-preview.35 +ARG AGENT_NODE_VERSION=2.5.0-preview.27 RUN apt-get update && apt-get install -y --no-install-recommends \ bash ca-certificates curl jq procps python3 python3-pexpect ripgrep unzip \ diff --git a/tests/test384-opencode-local-package-e2e/run.sh b/tests/test384-opencode-local-package-e2e/run.sh index e85b7945..2be9b0cc 100644 --- a/tests/test384-opencode-local-package-e2e/run.sh +++ b/tests/test384-opencode-local-package-e2e/run.sh @@ -13,8 +13,8 @@ ADMIN_PASSWORD='Test384-Strong-Password!' LIVE_ALIAS=wizard-openai FREE_MODEL="${OPENCODE_FREE_MODEL:-opencode/deepseek-v4-flash-free}" EXPECTED_OPENCODE="${OPENCODE_VERSION_UNDER_TEST:-1.18.1}" -EXPECTED_NETWORK="${AGENT_NETWORK_VERSION_UNDER_TEST:-2.3.0-preview.34}" -EXPECTED_NODE="${AGENT_NODE_VERSION_UNDER_TEST:-2.5.0-preview.26}" +EXPECTED_NETWORK="${AGENT_NETWORK_VERSION_UNDER_TEST:-2.3.0-preview.35}" +EXPECTED_NODE="${AGENT_NODE_VERSION_UNDER_TEST:-2.5.0-preview.27}" REAL_PATH="$PATH" FAKE_BIN_DIR=/test384/fake-bin FAKE_CANONICAL_BIN=/test384/fake-global/node_modules/opencode-ai/bin/opencode.exe @@ -944,6 +944,28 @@ cp "$ANTHROPIC_CONFIG_BACKUP" "$ANTHROPIC_CONFIG" chmod 600 "$ANTHROPIC_CONFIG" echo "PASS: downgraded bound OpenCode profile cannot launder its runtime through rename" +# A missing external binding is not permission to mint a replacement during +# rename. Temporarily remove the exact record and prove the preflight refuses +# before config persistence, copy, or lock creation; then restore the fixture. +ANTHROPIC_BINDING=$(grep -Rl '"nodeId": "wizard-anthropic"' "$BINDING_ROOT") +[[ -n "$ANTHROPIC_BINDING" ]] +ANTHROPIC_BINDING_BACKUP="$ROOT/wizard-anthropic.binding.before-missing-test" +mv "$ANTHROPIC_BINDING" "$ANTHROPIC_BINDING_BACKUP" +ANTHROPIC_CONFIG_HASH=$(sha256sum "$ANTHROPIC_CONFIG" | cut -d' ' -f1) +set +e +MISSING_BINDING_RENAME_OUT=$(cd "$WORK_DIR" && anet node rename wizard-anthropic should-not-bind 2>&1) +MISSING_BINDING_RENAME_RC=$? +set -e +[[ "$MISSING_BINDING_RENAME_RC" -ne 0 ]] +grep -q 'runtime binding is missing' <<<"$MISSING_BINDING_RENAME_OUT" +[[ "$(sha256sum "$ANTHROPIC_CONFIG" | cut -d' ' -f1)" = "$ANTHROPIC_CONFIG_HASH" ]] +[[ ! -e "$WORK_DIR/.anet/nodes/should-not-bind" ]] +[[ ! -e "$ANTHROPIC_NODE/rename.lock" ]] +[[ ! -e "$ANTHROPIC_BINDING" ]] +mv "$ANTHROPIC_BINDING_BACKUP" "$ANTHROPIC_BINDING" +chmod 600 "$ANTHROPIC_BINDING" +echo "PASS: missing OpenCode binding cannot be laundered into a new binding through rename" + # This node was created but never started, so rename exercises the local-only # rollback-safe branch. The external binding must move with the node identity: # one new record, no stale old-name record, and no net count change. @@ -955,34 +977,54 @@ BINDINGS_AFTER_RENAME=$(find "$BINDING_ROOT" -maxdepth 1 -type f -name '*.json' [[ "$BINDINGS_AFTER_RENAME" -eq "$BINDINGS_BEFORE" ]] ! grep -Rqs '"nodeId": "wizard-anthropic"' "$BINDING_ROOT" grep -Rqs '"nodeId": "wizard-anthropic-renamed"' "$BINDING_ROOT" +for private_dir in \ + "$RENAMED_ANTHROPIC_NODE" \ + "$RENAMED_ANTHROPIC_NODE/.config" \ + "$RENAMED_ANTHROPIC_NODE/.config/opencode" \ + "$RENAMED_ANTHROPIC_NODE/.local" \ + "$RENAMED_ANTHROPIC_NODE/.local/share" \ + "$RENAMED_ANTHROPIC_NODE/.local/share/opencode"; do + [[ "$(stat -c '%a' "$private_dir")" = "700" ]] +done echo "PASS: local-only OpenCode rename replaced the external binding without a stale old-name record" -# Delete must remove the exact external record before deleting project state. -# Reusing the same alias as an unrelated runtime then proves both cleanup and -# that an existing binding root plus HOME/.anet=0775 cannot gate other nodes. -(cd "$WORK_DIR" && anet node delete "$LIVE_ALIAS" --force) -[[ ! -e "$OPENAI_NODE" ]] -BINDINGS_AFTER_DELETE=$(find "$BINDING_ROOT" -maxdepth 1 -type f -name '*.json' | wc -l | tr -d ' ') -[[ "$BINDINGS_AFTER_DELETE" -eq $((BINDINGS_BEFORE - 1)) ]] -! grep -Rqs '"nodeId": "wizard-openai"' "$BINDING_ROOT" +# Bound OpenCode deletion has no durable lifecycle lock yet, so it must refuse +# before touching config or the external binding even after the node stopped. +OPENAI_CONFIG_HASH=$(sha256sum "$OPENAI_NODE/config.json" | cut -d' ' -f1) +set +e +DELETE_OUT=$(cd "$WORK_DIR" && anet node delete "$LIVE_ALIAS" --force 2>&1) +DELETE_RC=$? +set -e +[[ "$DELETE_RC" -ne 0 ]] +grep -q 'refusing delete of bound opencode-cli node' <<<"$DELETE_OUT" +[[ -d "$OPENAI_NODE" ]] +[[ "$(sha256sum "$OPENAI_NODE/config.json" | cut -d' ' -f1)" = "$OPENAI_CONFIG_HASH" ]] +BINDINGS_AFTER_DELETE_REFUSAL=$(find "$BINDING_ROOT" -maxdepth 1 -type f -name '*.json' | wc -l | tr -d ' ') +[[ "$BINDINGS_AFTER_DELETE_REFUSAL" -eq "$BINDINGS_BEFORE" ]] +grep -Rqs '"nodeId": "wizard-openai"' "$BINDING_ROOT" +echo "PASS: bound OpenCode delete refuses before config or binding mutation" +# An unrelated unbound runtime remains independent of the populated binding +# namespace, including the supported HOME/.anet=0775 private-group layout. chmod 775 "$HOME/.anet" -mkdir -p "$OPENAI_NODE" -chmod 700 "$OPENAI_NODE" -cat >"$OPENAI_NODE/config.json" <<'JSON' +REUSE_ALIAS=unbound-runtime-reuse +REUSE_NODE="$WORK_DIR/.anet/nodes/$REUSE_ALIAS" +mkdir -p "$REUSE_NODE" +chmod 700 "$REUSE_NODE" +cat >"$REUSE_NODE/config.json" <<'JSON' { "anet_version": "test384-reuse", "node_id": "n_test384_reuse", - "node_name": "wizard-openai", - "alias": "wizard-openai", - "runtime": "future-runtime-after-delete" + "node_name": "unbound-runtime-reuse", + "alias": "unbound-runtime-reuse", + "runtime": "future-runtime-unbound" } JSON -chmod 600 "$OPENAI_NODE/config.json" +chmod 600 "$REUSE_NODE/config.json" set +e -REUSE_OUT=$(cd "$WORK_DIR" && anet node start "$LIVE_ALIAS" 2>&1) +REUSE_OUT=$(cd "$WORK_DIR" && anet node start "$REUSE_ALIAS" 2>&1) REUSE_RC=$? set -e [[ "$REUSE_RC" -ne 0 ]] -grep -q 'unsupported runtime "future-runtime-after-delete"' <<<"$REUSE_OUT" -echo "PASS: delete removed the binding; same-name non-OpenCode reuse bypassed HOME/.anet=0775 and reached strict runtime validation" +grep -q 'unsupported runtime "future-runtime-unbound"' <<<"$REUSE_OUT" +echo "PASS: unrelated unbound runtime bypassed populated binding namespace and reached strict runtime validation" diff --git a/tests/test386-opencode-agent-node-gate/Dockerfile b/tests/test386-opencode-agent-node-gate/Dockerfile index 35d987b9..21ca20f2 100644 --- a/tests/test386-opencode-agent-node-gate/Dockerfile +++ b/tests/test386-opencode-agent-node-gate/Dockerfile @@ -1,7 +1,7 @@ FROM oven/bun:1.3.1 RUN apt-get update \ - && apt-get install -y --no-install-recommends jq python3 make g++ git \ + && apt-get install -y --no-install-recommends jq python3 make g++ git tmux \ && rm -rf /var/lib/apt/lists/* WORKDIR /repo/agent-network @@ -33,7 +33,7 @@ RUN umask 0002 \ USER root RUN chmod 755 /test/run.sh /test/bin/* /test/fail-bin/* /test/capable-bin/* /test/profile-bin/* \ - /test/project-bin/* \ + /test/project-bin/* /test/stop-fixture/agent-node \ && chmod 644 /test/loader-canary.cjs \ && chmod 644 /test/exact-node/package.json \ && chmod 755 /test/exact-node/dist/cli.js \ diff --git a/tests/test386-opencode-agent-node-gate/bin/npx b/tests/test386-opencode-agent-node-gate/bin/npx index 290a978c..99b2a647 100644 --- a/tests/test386-opencode-agent-node-gate/bin/npx +++ b/tests/test386-opencode-agent-node-gate/bin/npx @@ -4,7 +4,7 @@ set -eu printf '%s\n' "$*" > /tmp/test386-npx-args if [ "$#" -eq 3 ] \ && [ "$1" = "-y" ] \ - && [ "$2" = "@sleep2agi/agent-node@2.5.0-preview.26" ] \ + && [ "$2" = "@sleep2agi/agent-node@2.5.0-preview.27" ] \ && [ "$3" = "--print-entrypoint" ]; then printf '%s\n' '/test/exact-global/node_modules/@sleep2agi/agent-node/dist/cli.js' exit 0 diff --git a/tests/test386-opencode-agent-node-gate/exact-node/package.json b/tests/test386-opencode-agent-node-gate/exact-node/package.json index 51b8e076..4ce29f67 100644 --- a/tests/test386-opencode-agent-node-gate/exact-node/package.json +++ b/tests/test386-opencode-agent-node-gate/exact-node/package.json @@ -1,6 +1,6 @@ { "name": "@sleep2agi/agent-node", - "version": "2.5.0-preview.26", + "version": "2.5.0-preview.27", "type": "module", "bin": { "agent-node": "dist/cli.js" diff --git a/tests/test386-opencode-agent-node-gate/project-agent-node/package.json b/tests/test386-opencode-agent-node-gate/project-agent-node/package.json index 51b8e076..4ce29f67 100644 --- a/tests/test386-opencode-agent-node-gate/project-agent-node/package.json +++ b/tests/test386-opencode-agent-node-gate/project-agent-node/package.json @@ -1,6 +1,6 @@ { "name": "@sleep2agi/agent-node", - "version": "2.5.0-preview.26", + "version": "2.5.0-preview.27", "type": "module", "bin": { "agent-node": "dist/cli.js" diff --git a/tests/test386-opencode-agent-node-gate/run.sh b/tests/test386-opencode-agent-node-gate/run.sh index 7d65316d..c3a02df0 100644 --- a/tests/test386-opencode-agent-node-gate/run.sh +++ b/tests/test386-opencode-agent-node-gate/run.sh @@ -38,11 +38,12 @@ bun test \ src/opencode-package-binary.test.ts \ src/opencode-owner-mode.test.ts \ src/opencode-launch-env.test.ts \ + src/opencode-wrapper-stop.test.ts \ src/opencode-auth-login.test.ts \ src/opencode-smoke-env.test.ts \ src/opencode-runtime-binding.test.ts \ src/opencode-preset.test.ts >> "$REPORT" 2>&1 -pass "exact pairing plus launch, auth-login, smoke-env, and preset security unit tests" +pass "exact pairing plus launch, TERM-only wrapper stop, auth-login, smoke-env, and preset security unit tests" # Exercise the agent-node half of the hardened pair before either release # bundle is built. Keep the whole OpenCode ACP unit family together so a @@ -112,6 +113,172 @@ pass "agent-network typecheck" bun run build >> "$REPORT" 2>&1 pass "agent-network release bundle build" +# Bundle-level lifecycle closure for bound OpenCode. These fixtures have the +# exact wrapper argv shape but no Hub/model dependency. A same-name tmux canary +# proves authoritative stop never mutates alias-only state. +pid_live_non_zombie() { + local pid="$1" + [ -r "/proc/$pid/stat" ] || return 1 + local state + state=$(sed -E 's/^.*\) ([A-Z]).*$/\1/' "/proc/$pid/stat") + [ "$state" != "Z" ] && [ "$state" != "X" ] +} +wait_pid_gone() { + local pid="$1" + local deadline=$((SECONDS + 5)) + while pid_live_non_zombie "$pid"; do + [ "$SECONDS" -lt "$deadline" ] || return 1 + sleep 0.05 + done +} +assert_stop_binding() { + local node_dir="$1" + local home_dir="$2" + STOP_NODE_DIR="$node_dir" STOP_HOME_DIR="$home_dir" bun -e ' + import { readOpencodeRuntimeBinding } from "/repo/agent-network/src/opencode-runtime-binding.ts"; + if (!readOpencodeRuntimeBinding(process.env.STOP_NODE_DIR!, process.env.STOP_HOME_DIR!)) { + process.exit(1); + } + ' +} +make_stop_fixture() { + local root="$1" + local node_id="$2" + local current_alias="$3" + local launched_alias="$4" + local child_pid_file="${5:-}" + local home="$root/home" + local work="$root/work" + local node="$work/.anet/nodes/$node_id" + rm -rf "$root" + mkdir -p "$node" "$home" + chmod 700 "$root" "$home" "$work" "$work/.anet" "$work/.anet/nodes" "$node" + jq --arg alias "$current_alias" --arg node_id "$node_id" \ + '.alias = $alias | .node_name = $alias | .node_id = $node_id' \ + /test/config.json > "$node/config.json" + chmod 600 "$node/config.json" + write_opencode_binding "$node" "$home" + STOP_FIXTURE_CHILD_PID_FILE="$child_pid_file" \ + /test/stop-fixture/agent-node \ + --config "$node/config.json" --alias "$launched_alias" --runtime opencode-cli & + STOP_FIXTURE_PID=$! + printf '%s' "$STOP_FIXTURE_PID" > "$node/.pid" + chmod 600 "$node/.pid" + sleep 0.1 + pid_live_non_zombie "$STOP_FIXTURE_PID" || fail "bound stop fixture did not start" +} + +STOP_ROOT=/tmp/test386-bound-stop +make_stop_fixture "$STOP_ROOT" stop-gate renamed-after-launch old-launch-alias +STOP_PID=$STOP_FIXTURE_PID +tmux new-session -d -s renamed-after-launch 'sleep 60' +set +e +( + cd "$STOP_ROOT/work" + HOME="$STOP_ROOT/home" timeout 15 \ + bun /repo/agent-network/dist/bin/cli.js node stop stop-gate +) >/tmp/test386-bound-stop.log 2>&1 +bound_stop_rc=$? +set -e +mask_log > "$REPORT" +[ "$bound_stop_rc" -eq 0 ] || fail "authoritative bound stop rejected responsive alias-mutated wrapper" +wait_pid_gone "$STOP_PID" || fail "authoritative bound stop left responsive wrapper" +[ ! -e "$STOP_ROOT/work/.anet/nodes/stop-gate/.pid" ] || fail "successful bound stop retained pid file" +tmux has-session -t renamed-after-launch 2>/dev/null \ + || fail "bound stop killed same-name tmux canary" +tmux kill-session -t renamed-after-launch +assert_stop_binding "$STOP_ROOT/work/.anet/nodes/stop-gate" "$STOP_ROOT/home" \ + || fail "successful bound stop removed external binding" +pass "bound stop ignores mutable alias, verifies exact wrapper exit, and retains same-name tmux/binding" + +WEDGED_ROOT=/tmp/test386-bound-wedged +WEDGED_CHILD_FILE=/tmp/test386-bound-wedged-child +rm -f "$WEDGED_CHILD_FILE" +make_stop_fixture "$WEDGED_ROOT" wedged-gate wedged-gate wedged-gate "$WEDGED_CHILD_FILE" +WEDGED_PID=$STOP_FIXTURE_PID +for _ in $(seq 1 100); do [ -s "$WEDGED_CHILD_FILE" ] && break; sleep 0.02; done +[ -s "$WEDGED_CHILD_FILE" ] || fail "wedged fixture did not report detached child" +WEDGED_CHILD_PID=$(cat "$WEDGED_CHILD_FILE") + +# Project/batch/delete/rename entry points must refuse before any signal or mutation. +set +e +( + cd "$WEDGED_ROOT/work" + HOME="$WEDGED_ROOT/home" bun /repo/agent-network/dist/bin/cli.js project up +) >/tmp/test386-bound-project-up.log 2>&1 +project_up_rc=$? +( + cd "$WEDGED_ROOT/work" + HOME="$WEDGED_ROOT/home" bun /repo/agent-network/dist/bin/cli.js batch stop wedged \ + --workdir "$WEDGED_ROOT/work" +) >/tmp/test386-bound-batch.log 2>&1 +batch_stop_rc=$? +( + cd "$WEDGED_ROOT/work" + HOME="$WEDGED_ROOT/home" bun /repo/agent-network/dist/bin/cli.js node delete wedged-gate --force +) >/tmp/test386-bound-delete.log 2>&1 +delete_rc=$? +( + cd "$WEDGED_ROOT/work" + HOME="$WEDGED_ROOT/home" bun /repo/agent-network/dist/bin/cli.js \ + node rename wedged-gate should-not-rename --force +) >/tmp/test386-bound-rename.log 2>&1 +rename_rc=$? +set -e +mask_log > "$REPORT" +mask_log > "$REPORT" +mask_log > "$REPORT" +mask_log > "$REPORT" +pid_live_non_zombie "$WEDGED_PID" || fail "alternate lifecycle entry signaled bound wrapper" +pid_live_non_zombie "$WEDGED_CHILD_PID" || fail "alternate lifecycle entry orphaned detached child" +[ -e "$WEDGED_ROOT/work/.anet/nodes/wedged-gate/.pid" ] \ + || fail "alternate lifecycle entry removed bound pid file" +[ -e "$WEDGED_ROOT/work/.anet/nodes/wedged-gate/config.json" ] \ + || fail "bound delete removed config" +assert_stop_binding "$WEDGED_ROOT/work/.anet/nodes/wedged-gate" "$WEDGED_ROOT/home" \ + || fail "alternate lifecycle entry removed binding" +[ "$batch_stop_rc" -ne 0 ] || fail "batch stop did not fail closed for bound OpenCode" +[ "$delete_rc" -ne 0 ] || fail "bound delete did not fail closed" +[ "$rename_rc" -ne 0 ] || fail "running bound rename did not fail closed" +[ ! -e "$WEDGED_ROOT/work/.anet/nodes/should-not-rename" ] \ + || fail "running bound rename created target state" +[ ! -e "$WEDGED_ROOT/work/.anet/nodes/wedged-gate/rename.lock" ] \ + || fail "running bound rename left a lock" +grep -Fq "run 'anet node stop wedged-gate' first" /tmp/test386-bound-rename.log \ + || fail "running bound rename omitted authoritative stop instruction" +grep -Fq "explicit 'anet node start/stop'" /tmp/test386-bound-project-up.log \ + || fail "project up omitted bound lifecycle refusal" +pass "project up, batch stop, delete, and running rename preserve bound OpenCode ownership state" + +kill -STOP "$WEDGED_PID" +for _ in $(seq 1 100); do + state=$(sed -E 's/^.*\) ([A-Z]).*$/\1/' "/proc/$WEDGED_PID/stat") + [ "$state" = "T" ] && break + sleep 0.02 +done +[ "${state:-}" = "T" ] || fail "wrapper did not enter SIGSTOP state" +set +e +( + cd "$WEDGED_ROOT/work" + HOME="$WEDGED_ROOT/home" timeout 15 \ + bun /repo/agent-network/dist/bin/cli.js node stop wedged-gate +) >/tmp/test386-bound-wedged.log 2>&1 +wedged_stop_rc=$? +set -e +mask_log > "$REPORT" +[ "$wedged_stop_rc" -ne 0 ] || fail "SIGSTOP wrapper stop falsely succeeded" +pid_live_non_zombie "$WEDGED_PID" || fail "failed stop killed wrapper owner" +pid_live_non_zombie "$WEDGED_CHILD_PID" || fail "failed stop orphaned/killed detached child" +[ -e "$WEDGED_ROOT/work/.anet/nodes/wedged-gate/.pid" ] \ + || fail "failed stop removed pid file" +assert_stop_binding "$WEDGED_ROOT/work/.anet/nodes/wedged-gate" "$WEDGED_ROOT/home" \ + || fail "failed stop removed binding" +kill -CONT "$WEDGED_PID" +wait_pid_gone "$WEDGED_PID" || fail "resumed wrapper did not process queued TERM" +wait_pid_gone "$WEDGED_CHILD_PID" || fail "resumed wrapper did not reap detached child" +pass "SIGSTOP stop fails closed and retains wrapper, detached child, pid file, config, and binding" +rm -rf "$STOP_ROOT" "$WEDGED_ROOT" "$WEDGED_CHILD_FILE" + su -s /bin/bash bun -c \ 'umask 0002 && bun /test/nonroot-real-package.ts' >> "$REPORT" 2>&1 \ || fail "non-root umask-0002 real opencode-ai package identity gates" @@ -217,7 +384,7 @@ jq -e ' [ ! -e /tmp/test386-profile-coverage ] \ || fail "profile NODE_V8_COVERAGE wrote outside the node state boundary" [ ! -e /tmp/test386-npx-args ] || fail "exact global resolution unexpectedly executed npx" -grep -Fq 'using installed exact @sleep2agi/agent-node@2.5.0-preview.26' \ +grep -Fq 'using installed exact @sleep2agi/agent-node@2.5.0-preview.27' \ /tmp/test386-success.log || fail "exact installed agent-node diagnostic is missing" pass "stale global bypassed; later exact global received protected PATH/binary/version/base; npx was not executed" @@ -294,7 +461,7 @@ mask_log < /tmp/test386-project-explicit.log >> "$REPORT" || fail "explicit project-local rejection unexpectedly launched another agent-node" [ ! -e /tmp/test386-npx-args ] \ || fail "explicit project-local rejection unexpectedly executed npx" -grep -Fq 'ANET_AGENT_NODE_BIN is not the exact trusted @sleep2agi/agent-node@2.5.0-preview.26' \ +grep -Fq 'ANET_AGENT_NODE_BIN is not the exact trusted @sleep2agi/agent-node@2.5.0-preview.27' \ /tmp/test386-project-explicit.log \ || fail "explicit project-local rejection omitted the exact-pair diagnostic" grep -Fq 'project/node-local agent-node package payload is not trusted' \ @@ -336,7 +503,7 @@ jq -e '.executable == "/test/exact-global/node_modules/@sleep2agi/agent-node/dis /tmp/test386-exact-preview-launch.json >/dev/null \ || fail "capable-looking preview.21 was not bypassed for the later exact global" [ ! -e /tmp/test386-npx-args ] || fail "preview.21 bypass unexpectedly executed npx" -pass "capable-looking global preview.21 rejected; later exact global preview.26 launched without npx" +pass "capable-looking global preview.21 rejected; later exact global preview.27 launched without npx" # An explicit override is not permission to bypass the exact release pair. rm -rf /tmp/test386-work-explicit /tmp/test386-home-explicit \ @@ -362,7 +529,7 @@ mask_log < /tmp/test386-explicit.log >> "$REPORT" [ "$explicit_rc" -ne 0 ] || fail "stale explicit agent-node override unexpectedly started" [ ! -e /tmp/test386-stale-capable-global-was-launched ] \ || fail "stale explicit preview.21 was launched" -grep -Fq 'ANET_AGENT_NODE_BIN is not the exact trusted @sleep2agi/agent-node@2.5.0-preview.26' \ +grep -Fq 'ANET_AGENT_NODE_BIN is not the exact trusted @sleep2agi/agent-node@2.5.0-preview.27' \ /tmp/test386-explicit.log \ || fail "explicit override exact-version diagnostic is missing" pass "ANET_AGENT_NODE_BIN cannot bypass the exact hardened pair" @@ -395,7 +562,7 @@ mask_log < /tmp/test386-fail.log >> "$REPORT" grep -Fq 'automatic npx execution is disabled for opencode-cli' \ /tmp/test386-fail.log \ || fail "hard-fail omitted the disabled-npx diagnostic" -grep -Fq 'npm install -g @sleep2agi/agent-network@2.3.0-preview.34 @sleep2agi/agent-node@2.5.0-preview.26' \ +grep -Fq 'npm install -g @sleep2agi/agent-network@2.3.0-preview.35 @sleep2agi/agent-node@2.5.0-preview.27' \ /tmp/test386-fail.log \ || fail "hard-fail omitted the exact dual-package install command" grep -Fq 'Refusing to start: an unsupported agent-node could silently select another runtime.' \ diff --git a/tests/test386-opencode-agent-node-gate/stop-fixture/agent-node b/tests/test386-opencode-agent-node-gate/stop-fixture/agent-node new file mode 100644 index 00000000..77ace7a7 --- /dev/null +++ b/tests/test386-opencode-agent-node-gate/stop-fixture/agent-node @@ -0,0 +1,26 @@ +#!/usr/bin/env bun + +import { spawn } from "child_process"; +import { writeFileSync } from "fs"; + +let child: ReturnType | null = null; +if (process.env.STOP_FIXTURE_CHILD_PID_FILE) { + child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + detached: true, + stdio: "ignore", + }); + child.unref(); + writeFileSync(process.env.STOP_FIXTURE_CHILD_PID_FILE, String(child.pid)); +} + +const shutdown = () => { + if (child?.pid) { + try { process.kill(-child.pid, "SIGKILL"); } catch {} + } + process.exit(0); +}; + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); +process.on("SIGHUP", shutdown); +setInterval(() => {}, 1_000);