diff --git a/package.json b/package.json index 77520b5..b6303ef 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "create": "node scripts/create-plugin.mjs", "check": "npm run validate && npm test", - "test": "node --test", + "test": "node --test --test-concurrency=1", "validate": "node scripts/validate.mjs" }, "license": "Apache-2.0" diff --git a/plugins/Hylouis233/cli-agent-bridge/LICENSE b/plugins/Hylouis233/cli-agent-bridge/LICENSE new file mode 100644 index 0000000..1c08e39 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hylouis233 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/Hylouis233/cli-agent-bridge/NOTICE b/plugins/Hylouis233/cli-agent-bridge/NOTICE new file mode 100644 index 0000000..d31ad7d --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/NOTICE @@ -0,0 +1,14 @@ +cli-agent-bridge is an original implementation informed by the following +open-source projects. Their licenses are retained where applicable. + +- claude-subagent-mcp (https://github.com/ltxzs/claude-subagent-mcp) + MIT License. Copyright (c) ltxzs contributors. + Its headless spawn, timeout, capture-cap, and git-snapshot patterns + informed server.mjs. + +- subagent-mcp (https://github.com/Heretyc/subagent-mcp) + Apache License 2.0. Copyright 2026 Lexi Blackburn. + Its delegated-CLI orchestration concepts informed the Skill guidance. + +- wshobson/agents (https://github.com/wshobson/agents) + Reference for multi-harness agent plugin packaging patterns. diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md new file mode 100644 index 0000000..9843c2b --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -0,0 +1,240 @@ +# CLI Agent Bridge + +## The problem + +MiniMax Code users who also run Claude Code, Codex, Kimi Code, ZCode, or DSH want to keep +MiniMax Code as the single orchestrator while handing well-scoped implementation work to those +CLIs. Without a bridge, delegation means copying prompts between terminals and pasting results +back, with no record of what the worker changed. + +## What this Plugin does + +This Plugin ships one dependency-free stdio MCP server and one Skill. MiniMax Code calls the +three MCP tools to drive headless sessions of the other coding CLIs inside the same git +repository: + +- list_backends: report which coding CLIs are installed and available on this machine. +- workspace_status: git status, diff stat, and changed files before delegating work. +- delegate_task: run a self-contained task with a chosen backend CLI and return its exit + code, output tail, stderr tail, and the before/after git snapshots (staged, unstaged, + untracked, and committed deltas) the worker produced. Runs against the same workspace are + serialized across independent bridge processes; dirty trees are refused unless allowDirty=true; + cancellation and timeout terminate the complete worker process tree before the lock is released. + +The Skill teaches MiniMax Code when and how to delegate, and to review the returned diff before +reporting completion. + +## Try it + +```text +Use the cli-agent-bridge skill, then delegate the login-form refactor in this repository to codex. +``` + +Expected result: MiniMax Code checks workspace_status, confirms the tree is clean, runs +delegate_task with backend=codex and a self-contained task, then reviews the returned git status, +diff stat, changed files, and output tail before continuing. + +```text +Use cli-agent-bridge to have claude and kimi implement the same small feature independently, +then compare the two diffs. Create two independent clones first. +``` + +Expected result: the orchestrator creates two clones at the same starting commit, delegates the +same task to backend=claude in the first and backend=kimi in the second, then compares the two +diffs reported to the user. Linked worktrees share refs and therefore queue behind the same +repository lock; use separate clones when the comparison must run in parallel. + +## Requirements + +- Node.js 20 or newer to run the MCP server (the server has no npm dependencies). +- git available on PATH; the target workspace must be a git repository. +- Production delegation and workspace inspection are supported on Windows, where delegated + workers and potentially extensible Git helpers run inside a kill-on-close Job Object. + End-to-end verified with Claude Code 2.1.226 and Kimi Code 0.30.0. Linux, macOS, and BSD fail + closed before backend configuration, workspace access, Git resolution, or executable probing: + this dependency-free server cannot prove lifecycle containment after an arbitrary local worker + asks a host service to launch a process outside its original process tree. +- Each backend CLI must be installed, on PATH, and signed in with your own account before use: + +| Backend | CLI | Status | Headless form used | +|---|---|---|---| +| claude | Claude Code | verified end-to-end (2.1.226) | claude -p --output-format text --permission-mode acceptEdits | +| codex | OpenAI Codex CLI | documented non-interactive form | codex exec -- | +| kimi | Kimi Code | headless invocation verified (0.30.0) | kimi -p | + +The claude template passes `--permission-mode acceptEdits` so the headless worker can edit files +in the workspace without an interactive approval prompt; other permission levels can be selected +by editing backends.json. The kimi prompt mode (`-p`) accepts no permission flags on current +versions, so the worker runs with kimi's own non-interactive defaults. + +Experimental backends ship with a documented template and a note in list_backends: + +| Backend | CLI | Status | Headless form used | +|---|---|---|---| +| zcode | ZCode | experimental | zcode -p (desktop builds have no verified headless mode) | +| dsh | DeepSeek Harness | experimental | dsh --profile headless (requires a headless profile) | + +ZCode desktop builds have no verified headless CLI; point the command field at your own CLI if +your ZCode distribution provides one. The dsh template uses its documented headless profile +(dsh --profile headless), which must exist under DSH_HOME/profiles. Edit backends.json (or set +the CLI_AGENT_BRIDGE_BACKENDS environment variable to a custom file) to adjust command, args, or +binary paths. + +On Windows, the contained launcher recognizes the fixed format generated by npm for standard +`.cmd` shims and starts their real Node entry directly. This preserves empty, multiline, Unicode, +quoted, and metacharacter-containing task arguments without a `cmd.exe` reinterpretation. +PowerShell `.ps1` backends use a UTF-8 JSON runner. An argument-bearing non-standard `.cmd`/`.bat` +wrapper fails closed; point `command` at its underlying real executable in backends.json. + +## Data and network + +- This Plugin makes no network calls of its own and stores no credentials, tokens, or session logs. + Per-user temporary lock records contain only process identity; if termination cannot be confirmed, + a quarantine marker containing the workspace path/backend/error persists until an operator explicitly + approves recovery as described below. +- delegate_task passes the task text to the backend CLI you choose, which runs with your own + local authentication and may contact that vendor or service for the requested work. +- The task text and workspace files are processed by the chosen backend provider. Never include + credentials, private endpoints, or personal data in a task. +- The server returns bounded command-output tails and Git snapshots to its MCP client; nothing is + transmitted elsewhere by the server itself. +- Workers run with the permission level baked into their template (claude: acceptEdits, which + auto-approves workspace file edits but still gates other tool classes) or with that CLI's own + non-interactive defaults. Review the returned diff before accepting the work. + +## Customizing backends + +backends.json maps each backend to a command template. The placeholders and +are substituted at run time. To use a differently named binary (for example a zcode wrapper), +change the command field. resumeSessionId is honored only for backends whose resumeArgs is set. +The bridge does not discover or parse session IDs from CLI output; pass resumeSessionId only when +you already obtained a valid ID from that backend outside this Plugin. + +## Limitations + +- MiniMax Code Agent Plugins 1.0 does not expose hooks, so context metering and automatic + orchestration switches from the upstream subagent-mcp design are out of scope here; the Skill + plays that role instead. +- The bridge delegates tasks; it does not merge code, commit, or push. The user reviews every + diff. +- Delegations and status snapshots targeting the same Git common directory are serialized even + when callers name a subdirectory, different path casing, symlink, or linked worktree, and even + when separate MCP clients launched separate bridge server processes. Use separate clean clones + for parallel comparison runs. The cross-process lock is an owner blob referenced by an atomic + Git-ref compare-and-swap in a private bare repository at + `/cli-agent-bridge-lock-store.git`. Keeping coordination refs out of the target + repository prevents `git push --mirror` from publishing host/process/token metadata. + The private store publishes its persistent UUID through an exclusive ordinary-file compatibility + anchor and the same-value UUID blob behind a create-only Git-ref CAS, without requiring hard-link + support. The bridge keeps a common-directory handle open through release. Renaming the repository + therefore cannot create a second lock domain or strand the original holder on its obsolete + pathname, while deleting and recreating a repository cannot inherit the old lock identity. A stale + idle lock is reclaimed only when its same-host owner is positively + confirmed dead; owner records include the bridge process start identity so a reused PID cannot + pin the queue. The host identity also includes the OS user, so another user cannot interpret a + user-scoped quarantine marker as cleared. Malformed, foreign-user/host, starting, running, or + uncertain records fail closed. + A crashed bridge can interrupt the durable state transition that follows worker cleanup, so inspect + the recorded process identity before clearing its coordination ref. +- Linked worktrees share refs and therefore intentionally share one repository lock. The + `repositoryConcurrency` field remains as a fail-safe disclosure if an older bridge instance or + an external writer updates bridge history during a snapshot, but current bridge instances do + not run linked-worktree delegations concurrently. +- Locking leaves the target repository refs, worktree, and index unchanged, but it requires writable + metadata in the private bare lock store. Its initialization inherits the enclosing repository's + `core.sharedRepository` mode for group/multi-user repositories. Each acquisition writes an owner + blob and temporarily updates a coordination ref there. Before returning an acquired lease, it + also publishes a unique activity-object ID. Snapshots compare activity IDs rather than clocks + from different hosts, and any extant foreign lease ref (including idle, pending, malformed, or + quarantined state) remains a conservative attribution-overlap signal. + Each normal release schedules Git's safe automatic + maintenance for superseded state. A failed release first leaves an exact-owner recovery record in + the shared store, so another bridge process can finish cleanup after the transient failure clears. + For these reasons workspace_status is not marked + read-only in its MCP annotations even though the snapshot itself does not edit worktree files. +- Cancellation and timeout confirm that the delegated process tree has exited before releasing + the workspace mutex. On the supported Windows production path, a kill-on-close Job Object is the + kernel lifecycle boundary; the bridge does not substitute PID ancestry polling or inherited + environment markers on unsupported platforms. Repository discovery and read-only snapshots + resolve Git before entering the workspace and explicitly disable repository hooks, fsmonitor, + pagers, external diff drivers, text conversion, and detached automatic maintenance. + Worktree status and unstaged diff commands are additionally process-contained because Git may + still invoke repository-configured clean filters while reading working-tree content. + Coordination-store Git commands use the same hook-free configuration, while operations that + create the store or a temporary tree remain process-contained. If termination cannot be + confirmed, the bridge writes a shared + quarantine marker after first moving its lease into a non-reclaimable `quarantine-pending` state, + then advances the lease into the recoverable `quarantined` state, and every bridge + process refuses further delegation until an operator checks for leftovers and deliberately + renames the reported `quarantinePath` to `quarantinePath.recovery-approved`. That durable, + incident-bound rename authorizes the next delegation to reclaim the quarantined lease; simple + marker absence (for example, OS temporary-file cleanup) never authorizes recovery. If the bridge crashes mid-run, a lease recording a running + worker still cannot be reclaimed automatically (descendant liveness cannot be proven), and an + abrupt crash may leave no quarantine marker. Locate and inspect the retained lease explicitly: + first run `git rev-parse --path-format=absolute --git-common-dir`, append + `cli-agent-bridge-lock-store.git` to obtain ``, then run + `git --git-dir= for-each-ref --format="%(refname) %(objectname)" refs/cli-agent-bridge/workspace-locks/` + and `git --git-dir= cat-file blob `. The JSON owner record contains the + server/worker state and PIDs. Only after checking those processes and escaped descendants are + gone, clear the listed ref with `git --git-dir= update-ref -d `. The + quarantine marker itself lives under the repository's private + `cli-agent-bridge-lock-store.git/cli-agent-bridge-quarantines` directory. This makes repository + access, rather than a predictable shared temporary path, the collaboration boundary for manual + recovery (including repositories shared deliberately by multiple users). New markers are + atomically claimed directories, so publication does not depend on hard-link support and the + reported path is renamed the same way during recovery. +- Cancelling a workspace_status request interrupts its queued lock wait or Git snapshot and returns + a cancelled tool result instead of performing a stale snapshot later. +- timeoutMs is an overall deadline that includes workspace lock acquisition, preflight Git checks, + the worker, and post-run snapshots. Safe process-tree termination can + extend beyond that deadline by the documented kill grace period. +- Snapshots include all Git refs as well as HEAD, so a worker that commits on a new branch and + returns to the original branch still reports the created ref and commit. Commits are attributed + to the worker only when they are not reachable from any pre-delegation ref, so checking out an + existing divergent branch is reported as a HEAD move with no new commits, and refs pointing at + non-commit objects (for example a blob tag) are reported without failing the delegation. A commit + reached through multiple moved refs is counted and logged once with all contributing labels; + ref namespaces alone never prove that a commit came from outside the worker. + Git exposes only the final, overwritable FETCH_HEAD and no complete cross-version per-fetch tip log. + A private, per-delegation Trace2 event stream therefore detects completed fetch/pull attempts in + the canonical workspace, including nonzero fetches that may have updated only some destinations; + when one occurred, the response keeps the worktree/ref snapshot but marks + commit attribution unavailable instead of guessing which commits were worker-created. The trace is + user-private, bounded, consumed after worker cleanup, and removed before the response. Runs without + fetch/pull batch commit-tip classification, and each changed target uses one boundary graph + walk, so repositories with thousands of refs do not spawn one Git process per baseline. Any + bounded Git capture that truncates is rejected as an unreliable snapshot; backend output + truncation is disclosed. +- zcode and dsh backends are experimental: ZCode desktop builds have no verified headless CLI, + and dsh needs a headless profile present under DSH_HOME/profiles. +- Custom wrapper shims that re-bind dashed flags can misreport a backend as unavailable; point + the backend command at the real executable to bypass the wrapper. +- The stdio transport accepts newline-delimited JSON-RPC with a 1,000,000-character per-line + limit. An unterminated or complete line beyond that bound is rejected and closes the dispatch + gate through the same awaited cleanup path as host disconnect. + +## Verification + +Run the dependency-free fake-backend suites from the repository root: + +```text +node --test plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs +node --test plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +node --test plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs +``` + +They cover the full MCP flow plus in-process and cross-process canonical worktree locking, stale +owner compare-and-swap, live-owner non-steal, quarantined-lease recovery after an explicit +operator approval rename, interruptible lease state updates, shared quarantine markers, queued and +discovery-phase cancellation (including list_backends probes), overall deadlines, cancel/timeout +Windows Job Object termination, internal fail-closed process-tree state-machine fixtures, +PID-reuse identity checks before signaling, unusual Git pathnames (including a trailing-space worktree +root), JSON-RPC id typing and bounded lines, unborn HEAD and non-HEAD ref changes, checkout-only HEAD moves, +single-count attribution for commits on the checked-out branch, fork-point diff baselines for +new branches, non-commit refs, fetched-history exclusion, repository-wide serialization and +failed-release recovery between linked worktrees, +capture truncation, and Codex prompt delimiters on Windows and POSIX. + +## License + +MIT. See LICENSE. Upstream credits: see NOTICE. diff --git a/plugins/Hylouis233/cli-agent-bridge/backends.json b/plugins/Hylouis233/cli-agent-bridge/backends.json new file mode 100644 index 0000000..a4877cf --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/backends.json @@ -0,0 +1,10 @@ +{ + "$comment": "Backend command templates for cli-agent-bridge. and are placeholders. Edit command or buildArgs to point at another binary or add flags. Never store credentials here; each CLI uses your own local authentication.", + "backends": { + "claude": { "label": "Claude Code", "command": "claude", "buildArgs": ["-p", "", "--output-format", "text", "--permission-mode", "acceptEdits"], "resumeArgs": ["-p", "", "--output-format", "text", "--permission-mode", "acceptEdits", "--resume", ""], "experimental": false }, + "codex": { "label": "OpenAI Codex CLI", "command": "codex", "buildArgs": ["exec", "--", ""], "resumeArgs": ["exec", "resume", "", "--", ""], "experimental": false }, + "kimi": { "label": "Kimi Code", "command": "kimi", "buildArgs": ["-p", ""], "resumeArgs": ["-S", "", "-p", ""], "experimental": false }, + "zcode": { "label": "ZCode", "command": "zcode", "buildArgs": ["-p", ""], "resumeArgs": null, "experimental": true, "notes": "Desktop ZCode builds have no verified headless mode; set command to your CLI if your distribution provides one." }, + "dsh": { "label": "DeepSeek Harness (dsh)", "command": "dsh", "buildArgs": ["--profile", "headless", ""], "resumeArgs": null, "experimental": true, "notes": "Uses the documented headless profile; requires a headless profile under DSH_HOME/profiles." } + } +} diff --git a/plugins/Hylouis233/cli-agent-bridge/config-reader.mjs b/plugins/Hylouis233/cli-agent-bridge/config-reader.mjs new file mode 100644 index 0000000..e4681b6 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/config-reader.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +import { open, writeFile } from "node:fs/promises"; + +const MAX_CONFIG_BYTES = 1_000_000; + +async function readBounded(file) { + const handle = await open(file, "r"); + try { + const chunks = []; + let length = 0; + while (length <= MAX_CONFIG_BYTES) { + const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, MAX_CONFIG_BYTES + 1 - length)); + const { bytesRead } = await handle.read(chunk, 0, chunk.length, null); + if (bytesRead === 0) break; + chunks.push(chunk.subarray(0, bytesRead)); + length += bytesRead; + } + if (length > MAX_CONFIG_BYTES) { + throw new Error("backend configuration exceeds the 1000000-byte limit"); + } + return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, length)); + } finally { + await handle.close(); + } +} + +const file = process.argv[2]; +if (typeof file !== "string" || !file) { + process.stderr.write("backend configuration path is missing\n"); + process.exitCode = 1; +} else { + try { + if (process.env.NODE_ENV === "test" && + process.env.CLI_AGENT_BRIDGE_TEST_BACKEND_CONFIG_READ_STALL_FILE) { + await writeFile( + process.env.CLI_AGENT_BRIDGE_TEST_BACKEND_CONFIG_READ_STALL_FILE, "started\n", + ); + await new Promise(() => { setInterval(() => {}, 1_000); }); + } + process.stdout.write(await readBounded(file)); + } catch (error) { + process.stderr.write(String(error?.message ?? error) + "\n"); + process.exitCode = 1; + } +} diff --git a/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs b/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs new file mode 100644 index 0000000..201d160 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs @@ -0,0 +1,211 @@ +import { constants } from "node:fs"; +import { access, appendFile, realpath, stat } from "node:fs/promises"; +import path from "node:path"; + +let executableEntry = null; +const pathCommandEntries = new Map(); + +async function resolveGitExecutable() { + if (process.env.NODE_ENV === "test") { + const delayMs = Number(process.env.CLI_AGENT_BRIDGE_TEST_GIT_RESOLUTION_DELAY_MS ?? 0); + if (Number.isFinite(delayMs) && delayMs > 0) { + const startedFile = process.env.CLI_AGENT_BRIDGE_TEST_GIT_RESOLUTION_STARTED_FILE; + if (typeof startedFile === "string" && path.isAbsolute(startedFile)) { + await appendFile(startedFile, "started\n"); + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + const names = process.platform === "win32" ? ["git.exe", "git.com"] : ["git"]; + for (const rawDirectory of (process.env.PATH ?? "").split(path.delimiter)) { + const directory = rawDirectory.replace(/^"|"$/gu, ""); + // Never let a relative PATH component reinterpret an untrusted workspace + // as an executable search root after a Git command changes cwd. + if (!directory || !path.isAbsolute(directory)) continue; + for (const name of names) { + const candidate = path.join(directory, name); + try { + await access(candidate, process.platform === "win32" ? constants.F_OK : constants.X_OK); + if (!(await stat(candidate)).isFile()) continue; + return await realpath(candidate); + } catch { /* try the next trusted PATH entry */ } + } + } + throw new Error("cannot locate git in an absolute PATH directory"); +} + +async function resolvePathCommandUncached(command) { + if (process.env.NODE_ENV === "test") { + const delayMs = Number(process.env.CLI_AGENT_BRIDGE_TEST_COMMAND_RESOLUTION_DELAY_MS ?? 0); + if (Number.isFinite(delayMs) && delayMs > 0) { + const startedFile = process.env.CLI_AGENT_BRIDGE_TEST_COMMAND_RESOLUTION_STARTED_FILE; + if (typeof startedFile === "string" && path.isAbsolute(startedFile)) { + await appendFile(startedFile, "started\n"); + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + if (path.isAbsolute(command)) { + try { + await access(command, process.platform === "win32" ? constants.F_OK : constants.X_OK); + return (await stat(command)).isFile() ? await realpath(command) : null; + } catch { return null; } + } + if (/[\\/]/u.test(command)) return null; + const extensions = process.platform === "win32" + ? (path.extname(command) + ? [""] + : [...(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean), ".ps1"]) + : [""]; + for (const rawDirectory of (process.env.PATH ?? "").split(path.delimiter)) { + const directory = rawDirectory.replace(/^"|"$/gu, ""); + if (!directory || !path.isAbsolute(directory)) continue; + for (const extension of extensions) { + const candidate = path.join(directory, command + extension); + try { + await access(candidate, process.platform === "win32" ? constants.F_OK : constants.X_OK); + if ((await stat(candidate)).isFile()) return await realpath(candidate); + } catch { /* continue searching */ } + } + } + return null; +} + +function pathCommandEntry(command) { + if (typeof command !== "string" || !command) { + return { promise: Promise.resolve(null), state: "fulfilled", value: null, error: null }; + } + if (!pathCommandEntries.has(command)) { + const entry = { + promise: resolvePathCommandUncached(command), + state: "pending", + value: null, + error: null, + waiters: new Set(), + }; + pathCommandEntries.set(command, entry); + // The core lookup has exactly one settlement reaction. Request-scoped + // waiters subscribe below and can be removed on cancel/deadline, so a + // permanently stalled filesystem lookup cannot retain one closure per + // abandoned request. + void entry.promise.then((resolved) => { + entry.state = "fulfilled"; + entry.value = resolved; + const waiters = [...entry.waiters]; + entry.waiters.clear(); + // Retain positive results, but retry a missing/not-yet-installed CLI. + if (resolved === null && pathCommandEntries.get(command) === entry) { + pathCommandEntries.delete(command); + } + for (const waiter of waiters) waiter.resolve(resolved); + }, (error) => { + entry.state = "rejected"; + entry.error = error; + const waiters = [...entry.waiters]; + entry.waiters.clear(); + if (pathCommandEntries.get(command) === entry) pathCommandEntries.delete(command); + for (const waiter of waiters) waiter.reject(error); + }); + } + return pathCommandEntries.get(command); +} + +export function resolvePathCommand(command) { + return pathCommandEntry(command).promise; +} + +export function subscribePathCommand(command, resolve, reject) { + const entry = pathCommandEntry(command); + if (entry.state !== "pending") { + let active = true; + queueMicrotask(() => { + if (!active) return; + if (entry.state === "rejected") reject(entry.error); + else resolve(entry.value); + }); + return () => { active = false; }; + } + const waiter = { resolve, reject }; + entry.waiters.add(waiter); + return () => { entry.waiters.delete(waiter); }; +} + +export function trustedGitExecutable() { + if (!executableEntry) { + const entry = { + promise: resolveGitExecutable(), + state: "pending", + value: null, + error: null, + waiters: new Set(), + }; + executableEntry = entry; + void entry.promise.then((resolved) => { + entry.state = "fulfilled"; + entry.value = resolved; + const waiters = [...entry.waiters]; + entry.waiters.clear(); + for (const waiter of waiters) waiter.resolve(resolved); + }, (error) => { + entry.state = "rejected"; + entry.error = error; + const waiters = [...entry.waiters]; + entry.waiters.clear(); + if (executableEntry === entry) executableEntry = null; + for (const waiter of waiters) waiter.reject(error); + }); + } + return executableEntry.promise; +} + +export function subscribeTrustedGitExecutable(resolve, reject) { + trustedGitExecutable(); + const entry = executableEntry; + if (entry.state !== "pending") { + let active = true; + queueMicrotask(() => { + if (!active) return; + if (entry.state === "rejected") reject(entry.error); + else resolve(entry.value); + }); + return () => { active = false; }; + } + const waiter = { resolve, reject }; + entry.waiters.add(waiter); + return () => { entry.waiters.delete(waiter); }; +} + +export async function safeGitInvocation( + args, baseEnvironment = process.env, resolvedExecutable = null, +) { + if (process.env.NODE_ENV === "test") { + const delayMs = Number(process.env.CLI_AGENT_BRIDGE_TEST_GIT_INVOCATION_DELAY_MS ?? 0); + if (Number.isFinite(delayMs) && delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + const safeArgs = [ + // Git documents /dev/null as the way to disable hooks. Unlike a shared + // empty directory, this sink cannot be pre-created or populated by another + // local user before a coordination update-ref operation. + "-c", "core.hooksPath=/dev/null", + "-c", "core.fsmonitor=false", + "-c", "gc.autoDetach=false", + "-c", "maintenance.auto=false", + ...args, + ]; + if (args[0] === "diff") safeArgs.splice(9, 0, "--no-ext-diff", "--no-textconv"); + // Repository-routing variables must never leak from the process that + // launched the bridge. Clear every case variant of GIT_* (Windows + // environment names are case-insensitive), then restore only the settings + // required by these local, non-interactive bridge operations. + const env = Object.fromEntries( + Object.entries(baseEnvironment).filter(([name]) => !/^GIT_/iu.test(name)), + ); + Object.assign(env, { + GIT_OPTIONAL_LOCKS: "0", + GIT_PAGER: "", + PAGER: "", + }); + return { command: resolvedExecutable ?? await trustedGitExecutable(), args: safeArgs, env }; +} diff --git a/plugins/Hylouis233/cli-agent-bridge/mcp.json b/plugins/Hylouis233/cli-agent-bridge/mcp.json new file mode 100644 index 0000000..a2081bf --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/mcp.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "cli-agent-bridge": { + "type": "stdio", + "command": "node", + "args": ["./server.mjs"] + } + } +} diff --git a/plugins/Hylouis233/cli-agent-bridge/plugin.json b/plugins/Hylouis233/cli-agent-bridge/plugin.json new file mode 100644 index 0000000..8bddb31 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/plugin.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "cli-agent-bridge", + "version": "0.1.0", + "description": "Delegate coding tasks from MiniMax Code to locally installed coding CLIs (Claude Code, Codex, Kimi Code, ZCode, DSH) through a dependency-free stdio MCP server with git-diff review.", + "author": { "name": "Hylouis233", "url": "https://github.com/Hylouis233" }, + "license": "MIT", + "keywords": ["minimax-code", "plugin", "mcp", "multi-agent", "delegation", "orchestration"] +} diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree-runner.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree-runner.mjs new file mode 100644 index 0000000..3d8d914 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree-runner.mjs @@ -0,0 +1,291 @@ +// Keep this small, known root alive until the bridge captures its creation +// identity, then launch the requested backend beneath it. This prevents a +// short-lived backend from disappearing before process-tree tracking starts. +import { spawn } from "node:child_process"; +import { readFileSync, statSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +let payloadText = ""; +let activeWorker = null; +const LINUX_EXIT_TRACKING_GRACE_MS = 150; +function workerEnvironment(payload) { + return payload.environment ?? process.env; +} +function recordWorkerExit(worker, code) { + if (activeWorker === worker) activeWorker = null; + const publishExit = () => { + process.exitCode = Number.isInteger(code) ? code : 1; + }; + // Keep the stable runner/root identity alive for several 25 ms ancestry + // samples after a very short Git/backend worker exits. This lets the bridge + // bind or rule out the worker's final children without weakening the + // pending-child fail-closed boundary. + if (process.platform === "linux") setTimeout(publishExit, LINUX_EXIT_TRACKING_GRACE_MS); + else publishExit(); +} +function trustedWindowsPowerShell() { + const windowsRoot = process.env.SystemRoot ?? process.env.WINDIR ?? ""; + if (!path.win32.isAbsolute(windowsRoot)) return null; + return path.win32.join( + windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe", + ); +} + +function trustedWindowsCommandProcessor() { + const windowsRoot = process.env.SystemRoot ?? process.env.WINDIR ?? ""; + if (!path.win32.isAbsolute(windowsRoot)) return null; + return path.win32.join(windowsRoot, "System32", "cmd.exe"); +} + +function isFile(candidate) { + try { return statSync(candidate).isFile(); } catch { return false; } +} + +function resolveWindowsCommand(command) { + const hasDirectory = path.win32.isAbsolute(command) || /[\\/]/u.test(command); + const searchRoots = hasDirectory + ? [process.cwd()] + : [process.cwd(), ...(process.env.PATH ?? "").split(path.delimiter)]; + const extensions = path.win32.extname(command) + ? [""] + : ["", ...(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") + .split(";").filter(Boolean), ".ps1"]; + for (const rawRoot of searchRoots) { + const root = rawRoot.replace(/^"|"$/gu, ""); + for (const extension of extensions) { + const unresolved = command + extension; + const candidate = path.win32.isAbsolute(unresolved) + ? unresolved + : path.resolve(root || process.cwd(), unresolved); + if (isFile(candidate)) return candidate; + } + } + return null; +} + +const CMD_META = /([()\][%!^"`<>&|;, *?])/gu; +function escapeCmdCommand(value) { + return value.replace(CMD_META, "^$1"); +} + +function parseStandardNpmCmdShim(commandFile) { + let source; + try { + if (statSync(commandFile).size > 64_000) return null; + source = readFileSync(commandFile, "utf8"); + } catch { + return null; + } + if (!/^[ \t]*SET dp0=%~dp0[ \t]*$/imu.test(source) || + !/^[ \t]*IF EXIST "%dp0%\\node\.exe" \([ \t]*$/imu.test(source) || + !/^[ \t]*SET "_prog=node"[ \t]*$/imu.test(source)) return null; + const invocation = source.match( + /^endLocal\s+&\s+goto\s+#_undefined_#\s+2>NUL\s+\|\|\s+title\s+%COMSPEC%\s+&\s+"%_prog%"\s+"%dp0%\\([^"\r\n]+)"\s+%\*\s*$/imu, + ); + if (!invocation || invocation[1].includes("%") || invocation[1].includes("!") || + path.win32.isAbsolute(invocation[1])) return null; + const shimDirectory = path.dirname(commandFile); + const entry = path.resolve(shimDirectory, invocation[1]); + const allowedRoot = path.basename(shimDirectory).toLowerCase() === ".bin" && + path.basename(path.dirname(shimDirectory)).toLowerCase() === "node_modules" + ? path.dirname(shimDirectory) + : shimDirectory; + const relativeEntry = path.relative(allowedRoot, entry); + if (!relativeEntry || relativeEntry.startsWith("..") || path.isAbsolute(relativeEntry)) return null; + if (!isFile(entry)) return null; + const adjacentNode = path.join(shimDirectory, "node.exe"); + return { + entry, + nodeExecutable: isFile(adjacentNode) ? adjacentNode : process.execPath, + }; +} + +function monitorWorker(worker) { + activeWorker = worker; + worker.once("error", (error) => { + process.stderr.write(error.message + "\n"); + process.exitCode = 127; + }); + worker.once("exit", (code) => { + recordWorkerExit(worker, code); + }); + return worker; +} + +function launchWindowsCmd(commandFile, payload) { + const commandProcessor = trustedWindowsCommandProcessor(); + if (!commandProcessor) { + process.stderr.write("cannot locate the trusted Windows command processor\n"); + process.exitCode = 127; + return; + } + const shellCommand = escapeCmdCommand(path.win32.normalize(commandFile)); + let worker; + try { + worker = spawn(commandProcessor, ["/d", "/s", "/c", `"${shellCommand}"`], { + cwd: process.cwd(), env: workerEnvironment(payload), windowsHide: true, + windowsVerbatimArguments: true, + stdio: [payload.stdinText === undefined ? "ignore" : "pipe", "inherit", "inherit"], + }); + } catch (error) { + process.stderr.write(error.message + "\n"); + process.exitCode = 127; + return; + } + monitorWorker(worker); + if (worker.stdin) { + worker.stdin.on("error", () => {}); + worker.stdin.end(payload.stdinText); + } +} + +function launchWindowsNpmShim(shim, payload) { + let worker; + try { + worker = spawn(shim.nodeExecutable, [shim.entry, ...payload.args], { + cwd: process.cwd(), env: workerEnvironment(payload), windowsHide: true, + stdio: [payload.stdinText === undefined ? "ignore" : "pipe", "inherit", "inherit"], + }); + } catch (error) { + process.stderr.write(error.message + "\n"); + process.exitCode = 127; + return; + } + monitorWorker(worker); + if (worker.stdin) { + worker.stdin.on("error", () => {}); + worker.stdin.end(payload.stdinText); + } +} + +function launchWindowsPowerShell(payload) { + const powershell = trustedWindowsPowerShell(); + if (!powershell) { + process.stderr.write("cannot locate the trusted Windows PowerShell executable\n"); + process.exitCode = 127; + return; + } + const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "ps1-json-runner.ps1"); + let worker; + try { + worker = spawn(powershell, [ + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", runner, + ], { + cwd: process.cwd(), env: workerEnvironment(payload), windowsHide: true, + stdio: ["pipe", "inherit", "inherit"], + }); + } catch (error) { + process.stderr.write(error.message + "\n"); + process.exitCode = 127; + return; + } + monitorWorker(worker); + worker.stdin.on("error", () => {}); + worker.stdin.end(JSON.stringify(payload)); +} + +function launchWindowsFallback(payload, launchError) { + const commandFile = resolveWindowsCommand(payload.command); + const extension = commandFile ? path.win32.extname(commandFile) : ""; + const recoverableError = ["EACCES", "EFTYPE", "EINVAL", "ENOENT", "EPERM", "UNKNOWN"] + .includes(launchError?.code); + if (!recoverableError || ![".bat", ".cmd", ".ps1"].includes(extension.toLowerCase())) { + process.stderr.write((launchError?.message ?? "backend launch failed") + "\n"); + process.exitCode = 127; + return; + } + if (extension.toLowerCase() === ".cmd" || extension.toLowerCase() === ".bat") { + const npmEntry = parseStandardNpmCmdShim(commandFile); + if (npmEntry) { + launchWindowsNpmShim(npmEntry, payload); + return; + } + if (payload.args.length > 0) { + process.stderr.write( + "cannot safely pass arguments to a non-standard .cmd/.bat backend; " + + "configure its real executable instead\n", + ); + process.exitCode = 127; + return; + } + launchWindowsCmd(commandFile, payload); + return; + } + launchWindowsPowerShell(commandFile ? { ...payload, command: commandFile } : payload); +} + +process.on("SIGTERM", () => { + // Keep the stable process-group leader alive through the graceful phase so + // the bridge can still prove and SIGKILL the same group if a backend ignores + // SIGTERM before ancestry polling records it. + if (activeWorker) { + try { activeWorker.kill("SIGTERM"); } catch { /* already gone */ } + } +}); +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { payloadText += chunk; }); +process.stdin.on("end", () => { + let payload; + try { + payload = JSON.parse(payloadText); + } catch (error) { + process.stderr.write("invalid process-tree runner payload: " + error.message + "\n"); + process.exitCode = 127; + return; + } + + const launch = (command, args, allowWindowsFallback) => { + let worker; + try { + worker = spawn(command, args, { + cwd: process.cwd(), + env: workerEnvironment(payload), + windowsHide: true, + stdio: [payload.stdinText === undefined ? "ignore" : "pipe", "inherit", "inherit"], + }); + } catch (error) { + if (allowWindowsFallback && process.platform === "win32") { + launchWindowsFallback(payload, error); + return; + } + process.stderr.write(error.message + "\n"); + process.exitCode = 127; + return; + } + activeWorker = worker; + let failed = false; + worker.once("error", (error) => { + if (failed) return; + failed = true; + if (allowWindowsFallback && process.platform === "win32") { + launchWindowsFallback(payload, error); + return; + } + process.stderr.write(error.message + "\n"); + process.exitCode = 127; + }); + worker.once("exit", (code) => { + if (!failed) recordWorkerExit(worker, code); + }); + if (worker.stdin) { + // A valid backend may exit without reading stdin. Its exit code, not an + // asynchronous EPIPE on the parent-side pipe, is authoritative. + worker.stdin.on("error", () => {}); + worker.stdin.end(payload.stdinText); + } + }; + + if (typeof payload.command !== "string" || !Array.isArray(payload.args) || + payload.command.includes("\0") || payload.args.some((argument) => + typeof argument !== "string" || argument.includes("\0")) || + (payload.environment !== undefined && + (!payload.environment || Array.isArray(payload.environment) || + typeof payload.environment !== "object" || + Object.values(payload.environment).some((value) => typeof value !== "string")))) { + process.stderr.write("invalid process-tree runner command\n"); + process.exitCode = 127; + return; + } + launch(payload.command, payload.args, true); +}); diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs new file mode 100644 index 0000000..80617ad --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs @@ -0,0 +1,953 @@ +import { spawn } from "node:child_process"; +import { readdir, readFile } from "node:fs/promises"; + +const UTILITY_CAPTURE_CHARS = 1_000_000; +const MARKER_OBSERVATION_GRACE_MS = 500; + +function appendBounded(current, chunk) { + const combined = current + chunk; + return combined.length > UTILITY_CAPTURE_CHARS ? combined.slice(-UTILITY_CAPTURE_CHARS) : combined; +} + +function runUtility(command, args, timeoutMs = 5_000, options = {}) { + return new Promise((resolve) => { + const child = spawn(command, args, { + env: options.env ?? process.env, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let stdoutTruncated = false; + let stderrTruncated = false; + let settled = false; + const done = (exitCode, error = null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ exitCode, stdout, stderr, error, stdoutTruncated, stderrTruncated }); + }; + const timer = setTimeout(() => { + try { child.kill("SIGKILL"); } catch { /* already gone */ } + done(null, new Error(command + " timed out")); + }, timeoutMs); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + if (stdout.length + chunk.length > UTILITY_CAPTURE_CHARS) stdoutTruncated = true; + stdout = appendBounded(stdout, chunk); + }); + child.stderr.on("data", (chunk) => { + if (stderr.length + chunk.length > UTILITY_CAPTURE_CHARS) stderrTruncated = true; + stderr = appendBounded(stderr, chunk); + }); + child.on("error", (error) => done(null, error)); + child.on("close", (code) => done(code)); + }); +} + +async function windowsProcessStartIdentity(pid, run = runUtility) { + const script = [ + "$ErrorActionPreference='Stop'", + `$p=Get-CimInstance Win32_Process -Filter 'ProcessId = ${String(pid)}'`, + "if ($null -eq $p -or $null -eq $p.CreationDate) { throw 'process identity is unavailable' }", + "$p.CreationDate.ToUniversalTime().Ticks.ToString()", + ].join("; "); + const result = await run("powershell.exe", [ + "-NoProfile", "-NonInteractive", "-Command", script, + ]); + const identity = result.stdout.trim(); + if (result.exitCode !== 0 || !/^\d+$/u.test(identity)) { + throw new Error("cannot inspect Windows process identity: " + + (result.stderr.trim() || result.error?.message || "unknown error")); + } + return identity; +} + +export function trackedWindowsProcessTreePids(rootPid, treeState, processes) { + treeState.knownStarts ??= new Map(); + if (treeState.windowsRootIdentityAttempted === true && + !treeState.knownStarts.has(rootPid)) { + throw new Error("cannot inspect Windows process tree: worker creation identity was not captured at startup"); + } + if (!Array.isArray(processes) || processes.some((item) => + !Number.isInteger(item.pid) || !Number.isInteger(item.parentPid) || + !/^\d+$/u.test(item.startIdentity))) { + throw new Error("cannot inspect Windows process tree: invalid process snapshot"); + } + const byPid = new Map(processes.map((item) => [item.pid, item])); + for (const pid of [...treeState.knownPids]) { + const item = byPid.get(pid); + const expected = treeState.knownStarts.get(pid); + if (item && expected && expected !== item.startIdentity) treeState.knownPids.delete(pid); + } + const descendants = new Set(); + const parents = new Set(); + const canBeChildOf = (item, parentPid) => { + const parentStart = treeState.knownStarts.get(parentPid); + return Boolean(parentStart) && BigInt(item.startIdentity) >= BigInt(parentStart); + }; + const acceptKnown = (pid) => { + const item = byPid.get(pid); + const expected = treeState.knownStarts.get(pid); + if (!item || !expected || expected !== item.startIdentity) return; + descendants.add(pid); + parents.add(pid); + }; + acceptKnown(rootPid); + for (const pid of treeState.knownPids) acceptKnown(pid); + if (!treeState.knownStarts.has(rootPid) && + (byPid.has(rootPid) || processes.some((item) => item.parentPid === rootPid))) { + throw new Error("cannot inspect Windows process tree: worker creation identity was not captured at startup"); + } + for (const [knownPid, knownStart] of treeState.knownStarts) { + const currentParent = byPid.get(knownPid); + const originalParentUnavailable = !currentParent || currentParent.startIdentity !== knownStart; + if (originalParentUnavailable && processes.some((item) => { + if (item.parentPid !== knownPid || treeState.knownStarts.has(item.pid) || + BigInt(item.startIdentity) < BigInt(knownStart)) return false; + return !currentParent || BigInt(item.startIdentity) <= BigInt(currentParent.startIdentity); + })) { + throw new Error("cannot inspect Windows process tree: unverified descendant remained after a tracked parent exited"); + } + } + let changed = true; + while (changed) { + changed = false; + for (const item of processes) { + if (!parents.has(item.parentPid) || descendants.has(item.pid) || + !canBeChildOf(item, item.parentPid)) continue; + descendants.add(item.pid); + parents.add(item.pid); + treeState.knownStarts.set(item.pid, item.startIdentity); + changed = true; + } + } + for (const pid of descendants) treeState.knownPids.add(pid); + return [...descendants]; +} + +export async function windowsProcessTreePids( + rootPid, + treeState = { knownPids: new Set(), knownStarts: new Map() }, + { runUtility: run = runUtility, windowsSnapshot } = {}, +) { + if (windowsSnapshot) { + return trackedWindowsProcessTreePids(rootPid, treeState, await windowsSnapshot({ + rootPid, knownPids: [...treeState.knownPids], + })); + } + const script = [ + "$ErrorActionPreference='Stop'", + "$items=@(Get-CimInstance Win32_Process | ForEach-Object {$identity=$(if($null -eq $_.CreationDate){''}else{$_.CreationDate.ToUniversalTime().Ticks.ToString()});[pscustomobject]@{ProcessId=[uint32]$_.ProcessId;ParentProcessId=[uint32]$_.ParentProcessId;CreationTicks=$identity}})", + "$items | ConvertTo-Json -Compress", + ].join("; "); + const result = await run("powershell.exe", [ + "-NoProfile", "-NonInteractive", "-Command", script, + ]); + if (result.exitCode !== 0) { + throw new Error("cannot inspect Windows process tree: " + (result.stderr.trim() || result.error?.message || "unknown error")); + } + const raw = result.stdout.trim(); + if (!raw) return []; + const parsed = JSON.parse(raw); + const processes = (Array.isArray(parsed) ? parsed : [parsed]).map((item) => ({ + pid: Number(item.ProcessId), + parentPid: Number(item.ParentProcessId), + startIdentity: String(item.CreationTicks ?? ""), + })); + return trackedWindowsProcessTreePids(rootPid, treeState, processes); +} + +export async function initializeProcessTree(child, treeState, options = {}) { + const platform = options.platform ?? process.platform; + const queryRootIdentity = options.queryRootIdentity ?? windowsProcessStartIdentity; + const run = options.runUtility ?? runUtility; + if (!Number.isInteger(child.pid)) return; + if (platform === "win32") { + treeState.knownStarts ??= new Map(); + treeState.windowsRootIdentityAttempted = true; + const identity = await queryRootIdentity(child.pid, run); + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error("Windows worker exited before startup identity was captured"); + } + treeState.knownPids.add(child.pid); + treeState.knownStarts.set(child.pid, identity); + return; + } + try { + const snapshot = await refreshProcessTree(child, treeState, { + ...options, platform, allowRootIdentityCapture: true, + }); + if (snapshot === null || treeState.processIdentityUncertain === true || + child.exitCode !== null || child.signalCode !== null || + !treeState.knownStarts?.get(child.pid)) { + treeState.knownPids.clear(); + treeState.knownPids.add(child.pid); + treeState.knownStarts.clear(); + throw new Error("POSIX worker identity was not captured while its process handle was live"); + } + } finally { + treeState.rootIdentityInitialized = true; + } +} + +function parseLinuxStat(pid, statLine) { + // comm is parenthesized and may itself contain ')' characters. Fields + // after the final ')' begin with: state, ppid, pgrp, ... + const close = statLine.lastIndexOf(")"); + if (close === -1) return null; + const fields = statLine.slice(close + 1).trim().split(/\s+/u); + const item = { + pid, + state: fields[0], + parentPid: Number(fields[1]), + processGroupId: Number(fields[2]), + startIdentity: fields[19] ?? "", // field 22: start time since boot + }; + return !item.state || !Number.isInteger(item.pid) || + !Number.isInteger(item.parentPid) || !Number.isInteger(item.processGroupId) + ? null : item; +} + +function isLinuxProcessGone(error) { + // procfs can report either ENOENT or ESRCH when a process disappears + // between directory enumeration and a subsequent stat/task read. + return error?.code === "ENOENT" || error?.code === "ESRCH"; +} + +async function readLinuxStat(pid, procRoot, fsOps) { + try { + return parseLinuxStat(pid, await fsOps.readFile(`${procRoot}/${pid}/stat`, "utf8")); + } catch (error) { + if (isLinuxProcessGone(error)) return undefined; + throw error; + } +} + +async function linuxProcessSnapshot(procRoot = "/proc", fsOps = { readdir, readFile }) { + let entries; + try { + entries = await fsOps.readdir(procRoot, { withFileTypes: true }); + } catch { + return null; + } + const processes = []; + for (const entry of entries) { + if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue; + try { + const item = await readLinuxStat(Number(entry.name), procRoot, fsOps); + if (item === undefined) continue; + if (item === null) return null; + processes.push(item); + } catch (error) { + return null; + } + } + processes.incomplete = false; + return processes; +} + +// Linux keeps zombie processes in /proc until their parent (sometimes a +// non-reaping container PID 1) collects them. This classifier is deliberately +// tri-state: true means a live member was found, false means every matching +// member is a zombie, and null means the result is uncertain. Callers may only +// use the false result after a group-wide SIGKILL; while a group is still +// running, enumerating /proc races with members that can fork. +export async function linuxProcessGroupHasLiveMembers( + processGroupId, + procRoot = "/proc", + fsOps = { readdir, readFile }, +) { + const processes = await linuxProcessSnapshot(procRoot, fsOps); + if (processes === null) return null; + const members = processes.filter((item) => item.processGroupId === processGroupId); + if (members.length === 0) return null; + return members.some((item) => isLiveState(item.state)); +} + +async function linuxMarkedProcesses(marker, procRoot, fsOps) { + let entries; + try { + entries = await fsOps.readdir(procRoot, { withFileTypes: true }); + } catch { + return null; + } + const expected = "CLI_AGENT_BRIDGE_RUN_ID=" + marker; + const matches = []; + matches.identityConflict = false; + for (const entry of entries) { + if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue; + try { + const pid = Number(entry.name); + const before = await readLinuxStat(pid, procRoot, fsOps); + if (before === undefined) continue; + if (before === null) return null; + const environment = await fsOps.readFile(`${procRoot}/${entry.name}/environ`); + const values = Buffer.isBuffer(environment) + ? environment.toString("utf8").split("\0") + : String(environment).split("\0"); + if (!values.includes(expected)) continue; + const after = await readLinuxStat(pid, procRoot, fsOps); + if (after && before.startIdentity && before.startIdentity === after.startIdentity) { + matches.push(after); + } else if (after && before.startIdentity && after.startIdentity && + before.startIdentity !== after.startIdentity) { + matches.identityConflict = true; + } + } catch (error) { + // Processes may exit or belong to another user while /proc is scanned. + // Neither case invalidates positive matches from this bridge's marker. + if (!isLinuxProcessGone(error) && !["EACCES", "EPERM"].includes(error.code)) return null; + } + } + return matches; +} + +async function linuxFallbackTrackedProcessSnapshot( + rootPid, + treeState, + procRoot, + fsOps, + allowRootIdentityCapture = false, +) { + const snapshot = await linuxProcessSnapshot(procRoot, fsOps); + if (snapshot === null) { + treeState.processIdentityUncertain = true; + return null; + } + treeState.knownStarts ??= new Map(); + const byPid = new Map(snapshot.map((item) => [item.pid, item])); + const accepted = new Map(); + const root = byPid.get(rootPid); + let expectedRoot = treeState.knownStarts.get(rootPid); + if (allowRootIdentityCapture && !expectedRoot && root?.startIdentity) { + const rootAfter = await readLinuxStat(rootPid, procRoot, fsOps); + if (!rootAfter || rootAfter.startIdentity !== root.startIdentity) { + treeState.processIdentityUncertain = true; + return null; + } + expectedRoot = root.startIdentity; + treeState.knownStarts.set(rootPid, expectedRoot); + } + if (root && expectedRoot && root.startIdentity === expectedRoot) { + accepted.set(rootPid, root); + treeState.knownPids.add(rootPid); + } + for (const [pid, expected] of treeState.knownStarts) { + const item = byPid.get(pid); + if (item?.startIdentity && item.startIdentity === expected) accepted.set(pid, item); + } + + let changed = true; + while (changed) { + changed = false; + for (const item of snapshot) { + if (accepted.has(item.pid) || !/^\d+$/u.test(item.startIdentity)) continue; + const parent = accepted.get(item.parentPid); + const rootGroupMember = accepted.has(rootPid) && item.processGroupId === rootPid; + if (!parent && !rootGroupMember) continue; + const relationshipAnchor = parent ?? accepted.get(rootPid); + if (!/^\d+$/u.test(relationshipAnchor.startIdentity) || + BigInt(item.startIdentity) < BigInt(relationshipAnchor.startIdentity)) { + treeState.processIdentityUncertain = true; + continue; + } + const current = await readLinuxStat(item.pid, procRoot, fsOps); + if (current === null) return null; + if (current === undefined) continue; + if (current.startIdentity !== item.startIdentity || + (parent && current.parentPid !== parent.pid) || + (rootGroupMember && current.processGroupId !== rootPid)) { + treeState.processIdentityUncertain = true; + continue; + } + const anchorNow = await readLinuxStat(relationshipAnchor.pid, procRoot, fsOps); + if (anchorNow === null) return null; + if (!anchorNow || anchorNow.startIdentity !== relationshipAnchor.startIdentity) { + treeState.processIdentityUncertain = true; + continue; + } + accepted.set(item.pid, current); + treeState.knownPids.add(item.pid); + treeState.knownStarts.set(item.pid, current.startIdentity); + changed = true; + } + } + + if (treeState.runMarker) { + const marked = await linuxMarkedProcesses(treeState.runMarker, procRoot, fsOps); + if (marked === null || marked.identityConflict === true) { + treeState.processIdentityUncertain = true; + return null; + } + for (const item of marked) { + if (item.pid === rootPid) continue; + const expected = treeState.knownStarts.get(item.pid); + if (expected && expected !== item.startIdentity) { + treeState.processIdentityUncertain = true; + continue; + } + treeState.knownPids.add(item.pid); + if (!expected) treeState.knownStarts.set(item.pid, item.startIdentity); + accepted.set(item.pid, item); + } + } + const processes = [...accepted.values()]; + // Discovery used a complete host snapshot, but the accepted ownership subset + // is intentionally narrower. Keep group probing enabled for final liveness. + processes.incomplete = true; + return processes; +} + +// Follow only PIDs already owned by this worker and the kernel-maintained child +// lists for their tasks. This keeps the short escape-detection interval without +// rescanning every process on the host for the lifetime of a delegation. +async function linuxTrackedProcessSnapshot( + rootPid, + treeState, + procRoot, + fsOps, + allowRootIdentityCapture = false, +) { + treeState.knownStarts ??= new Map(); + if (treeState.linuxTaskChildrenUnavailable === true) { + return await linuxFallbackTrackedProcessSnapshot( + rootPid, treeState, procRoot, fsOps, allowRootIdentityCapture, + ); + } + const queue = []; + const queued = new Set(); + const enqueue = (pid, parentPid = null, parentStartIdentity = null) => { + if (queued.has(pid)) return; + queued.add(pid); + queue.push({ pid, parentPid, parentStartIdentity }); + }; + enqueue(rootPid); + for (const pid of treeState.knownPids) enqueue(pid); + const processes = []; + const enqueueMarkedProcesses = async ({ stable = false } = {}) => { + if (!treeState.runMarker) return; + const scanMarkedProcesses = async () => { + const items = await linuxMarkedProcesses(treeState.runMarker, procRoot, fsOps); + if (items === null) { + treeState.processIdentityUncertain = true; + throw new Error("cannot inspect Linux run markers"); + } + if (items.identityConflict === true) { + treeState.processIdentityUncertain = true; + throw new Error("Linux run-marker process identity changed during inspection"); + } + return items; + }; + const identities = (items) => items + .map((item) => String(item.pid) + ":" + item.startIdentity) + .sort().join("\n"); + const observedIdentities = new Map(); + const rememberIdentities = (items) => { + for (const item of items) { + const previous = observedIdentities.get(item.pid); + if (previous && previous !== item.startIdentity) { + treeState.processIdentityUncertain = true; + throw new Error("Linux run-marker process identity changed between observations"); + } + observedIdentities.set(item.pid, item.startIdentity); + } + }; + let markedProcesses = await scanMarkedProcesses(); + rememberIdentities(markedProcesses); + if (stable) { + let stableObservation = false; + // A process can disappear between the first and second scans during a + // normal exit. Permit a bounded sequence of runner/worker transitions, + // but require two consecutive, identical full observations before + // treating the result as stable. + for (let attempt = 0; attempt < 5; attempt += 1) { + const repeated = await scanMarkedProcesses(); + rememberIdentities(repeated); + if (identities(markedProcesses) === identities(repeated)) { + markedProcesses = repeated; + stableObservation = true; + break; + } + markedProcesses = repeated; + } + if (!stableObservation) { + treeState.processIdentityUncertain = true; + throw new Error("Linux run-marker observation changed during process exit"); + } + } + for (const marked of markedProcesses) { + // The launch-time root identity is immutable. Marker recovery exists for + // escaped descendants, never to bless a recycled root PID. + if (marked.pid === rootPid) continue; + const expected = treeState.knownStarts.get(marked.pid); + // A recorded identity is immutable too. A stable marker proves that the + // current process inherited this run's environment, but it cannot prove + // that a recycled numeric PID is the same descendant observed earlier. + if (expected && expected !== marked.startIdentity) { + treeState.processIdentityUncertain = true; + continue; + } + if (!expected) treeState.knownStarts.set(marked.pid, marked.startIdentity); + enqueue(marked.pid); + } + }; + for (let index = 0; index < queue.length; index += 1) { + const { pid, parentPid, parentStartIdentity } = queue[index]; + let item; + try { + item = await readLinuxStat(pid, procRoot, fsOps); + } catch { + return null; + } + if (item === undefined) { + if (pid === rootPid || parentPid !== null) { + await enqueueMarkedProcesses({ stable: true }); + } + continue; + } + if (item === null) return null; + const expected = treeState.knownStarts.get(pid); + if (pid === rootPid && !expected && !allowRootIdentityCapture) { + await enqueueMarkedProcesses(); + continue; + } + if (expected && item.startIdentity && expected !== item.startIdentity) { + if (pid === rootPid) await enqueueMarkedProcesses(); + continue; + } + if (parentPid !== null && (item.parentPid !== parentPid || + !parentStartIdentity || BigInt(item.startIdentity) < BigInt(parentStartIdentity))) { + treeState.processIdentityUncertain = true; + await enqueueMarkedProcesses(); + continue; + } + treeState.knownPids.add(pid); + if (item.startIdentity) treeState.knownStarts.set(pid, item.startIdentity); + processes.push(item); + + const pendingChildren = new Set(); + let parentAfter = item; + let taskDirectoryGone = false; + let stableTaskSample = false; + for (let taskAttempt = 0; taskAttempt < 3; taskAttempt += 1) { + let taskEntries; + try { + taskEntries = await fsOps.readdir(`${procRoot}/${pid}/task`, { withFileTypes: true }); + } catch (error) { + if (isLinuxProcessGone(error)) { + taskDirectoryGone = true; + break; + } + return null; + } + let taskChangedWhileReading = false; + for (const taskEntry of taskEntries) { + if (!taskEntry.isDirectory() || !/^\d+$/u.test(taskEntry.name)) continue; + let children; + try { + children = await fsOps.readFile( + `${procRoot}/${pid}/task/${taskEntry.name}/children`, "utf8", + ); + } catch (error) { + if (allowRootIdentityCapture && pid === rootPid && taskEntry.name === String(rootPid) && + error?.code === "ENOENT") { + let taskEntriesAfter; + try { + taskEntriesAfter = await fsOps.readdir(`${procRoot}/${pid}/task`, { + withFileTypes: true, + }); + } catch (confirmError) { + if (!isLinuxProcessGone(confirmError)) return null; + taskEntriesAfter = []; + } + const mainTaskStillPresent = taskEntriesAfter.some((entry) => + entry.isDirectory() && entry.name === String(rootPid)); + const rootAfter = mainTaskStillPresent + ? await readLinuxStat(pid, procRoot, fsOps) + : undefined; + if (rootAfter && rootAfter.startIdentity === item.startIdentity) { + treeState.linuxTaskChildrenUnavailable = true; + return await linuxFallbackTrackedProcessSnapshot( + rootPid, treeState, procRoot, fsOps, allowRootIdentityCapture, + ); + } + } + if (isLinuxProcessGone(error)) { + taskChangedWhileReading = true; + continue; + } + return null; + } + for (const value of children.trim().split(/\s+/u)) { + if (!value) continue; + const childPid = Number(value); + if (!Number.isInteger(childPid) || childPid <= 0) continue; + // Preserve evidence from every torn attempt. A later complete task + // pass cannot make a previously observed child safe to forget. + pendingChildren.add(childPid); + } + } + try { + parentAfter = await readLinuxStat(pid, procRoot, fsOps); + } catch { + return null; + } + if (!parentAfter || parentAfter.startIdentity !== item.startIdentity) break; + if (!taskChangedWhileReading) { + stableTaskSample = true; + break; + } + // Recover marker-bearing escapees immediately, then retry the complete + // task list while the original parent identity is still live. + await enqueueMarkedProcesses(); + } + if (taskDirectoryGone) { + await enqueueMarkedProcesses({ stable: true }); + if ([...pendingChildren].some((childPid) => !treeState.knownStarts.has(childPid))) { + treeState.processIdentityUncertain = true; + } + continue; + } + if (!parentAfter || parentAfter.startIdentity !== item.startIdentity) { + // A short-lived, childless runner normally disappears between its task + // listing and this identity recheck. Require two identical full marker + // scans before accepting that clean exit. Any not-yet-verified child, a + // replacement parent identity, a marker conflict, or an unstable scan + // remains sticky/fail-closed. A previously bound child is independently + // rechecked from the initial queue with its immutable start identity. + await enqueueMarkedProcesses({ stable: true }); + const hasUnverifiedPendingChild = [...pendingChildren] + .some((childPid) => !treeState.knownStarts.has(childPid)); + if (hasUnverifiedPendingChild || + parentAfter === null || + (parentAfter && parentAfter.startIdentity !== item.startIdentity) || + treeState.processIdentityUncertain === true) { + treeState.processIdentityUncertain = true; + } + continue; + } + if (!stableTaskSample) { + // Repeated task churn means at least one task's children were never + // observed in a complete pass. Preserve every positive child candidate, + // but keep liveness sticky so the workspace cannot be released on a + // potentially incomplete ancestry proof. + treeState.processIdentityUncertain = true; + } + for (const childPid of pendingChildren) { + enqueue(childPid, pid, item.startIdentity); + } + } + // This targeted ancestry/marker walk is not a complete process-group scan. + processes.incomplete = true; + return processes; +} + +export async function posixProcessSnapshot({ + platform = process.platform, + procRoot = "/proc", + fsOps = { readdir, readFile }, + runUtility: run = runUtility, +} = {}) { + if (platform === "linux") return await linuxProcessSnapshot(procRoot, fsOps); + const result = await run( + "ps", ["-axo", "pid=,ppid=,pgid=,stat=,lstart="], 5_000, + { env: { ...process.env, LC_ALL: "C", LANG: "C" } }, + ); + if (result.exitCode !== 0 || result.stdoutTruncated === true) return null; + const processes = []; + for (const line of result.stdout.split(/\r?\n/u)) { + if (!line.trim()) continue; + const item = parsePosixProcessLine(line); + if (!item) return null; + processes.push(item); + } + processes.incomplete = false; + return processes; +} + +export function parsePosixProcessLine(line) { + const fields = line.trim().split(/\s+/u); + if (fields.length < 9) return null; + const item = { + pid: Number(fields[0]), + parentPid: Number(fields[1]), + processGroupId: Number(fields[2]), + state: fields[3][0] ?? "", + // BSD/macOS ps lstart: "Mon Aug 16 12:34:56 2026". Keeping the + // complete timestamp lets later snapshots reject a reused PID. + startIdentity: fields.slice(4).join(" "), + }; + return Number.isInteger(item.pid) && Number.isInteger(item.parentPid) && + Number.isInteger(item.processGroupId) && item.state && item.startIdentity + ? item : null; +} + +function isLiveState(state) { + return state !== "Z" && state !== "X" && state !== "x"; +} + +export async function refreshProcessTree(child, treeState, options = {}) { + if (!Number.isInteger(child.pid)) return null; + const platform = options.platform ?? process.platform; + if (platform === "win32") { + await windowsProcessTreePids(child.pid, treeState, options); + return null; + } + const processes = options.posixProcessSnapshot + ? await options.posixProcessSnapshot(options) + : platform === "linux" + ? await linuxTrackedProcessSnapshot( + child.pid, + treeState, + options.procRoot ?? "/proc", + options.fsOps ?? { readdir, readFile }, + options.allowRootIdentityCapture === true, + ) + : await posixProcessSnapshot(options); + if (processes === null) return null; + treeState.knownStarts ??= new Map(); + const byPid = new Map(processes.map((item) => [item.pid, item])); + // Remember the leader's own start identity so a later signal or liveness + // check can detect that the PID exited and was reused by another process. + const leader = byPid.get(child.pid); + const expectedLeaderStart = treeState.knownStarts.get(child.pid); + if (options.allowRootIdentityCapture === true && leader?.startIdentity && + !expectedLeaderStart) { + treeState.knownStarts.set(child.pid, leader.startIdentity); + } + const leaderIsOriginal = Boolean(leader?.startIdentity) && + treeState.knownStarts.get(child.pid) === leader.startIdentity; + const matchesKnownIdentity = (item) => { + const expected = treeState.knownStarts.get(item.pid); + return Boolean(expected) && Boolean(item.startIdentity) && expected === item.startIdentity; + }; + // A recycled leader PID must not seed ancestry or process-group discovery: + // doing so would adopt and later signal the replacement process's children. + const parents = new Set(leaderIsOriginal ? [child.pid] : []); + for (const pid of treeState.knownPids) { + const item = byPid.get(pid); + if (item && matchesKnownIdentity(item)) parents.add(pid); + } + let changed = true; + while (changed) { + changed = false; + for (const item of processes) { + if (((leaderIsOriginal && item.processGroupId === child.pid) || parents.has(item.parentPid)) && + !parents.has(item.pid)) { + parents.add(item.pid); + treeState.knownPids.add(item.pid); + if (item.startIdentity) treeState.knownStarts.set(item.pid, item.startIdentity); + changed = true; + } + } + } + return processes; +} + +export async function isProcessTreeAlive(child, treeState, { + ignoreZombieOnly = false, + platform = process.platform, + procRoot = "/proc", + fsOps = { readdir, readFile }, + probeProcessGroup = (processGroupId) => process.kill(-processGroupId, 0), + posixProcessSnapshot, + runUtility: run = runUtility, + windowsSnapshot, +} = {}) { + if (!Number.isInteger(child.pid)) return false; + if (platform === "win32") { + await treeState.initialRefresh; + return (await windowsProcessTreePids(child.pid, treeState, { + runUtility: run, windowsSnapshot, + })).length > 0; + } + let processes = await refreshProcessTree(child, treeState, { + platform, procRoot, fsOps, posixProcessSnapshot, runUtility: run, + }); + if (treeState.processIdentityUncertain === true) return true; + const snapshotHasTrackedLive = (snapshot) => { + const knownStarts = treeState.knownStarts ?? new Map(); + const leaderStart = knownStarts.get(child.pid); + const currentLeader = snapshot.find((item) => item.pid === child.pid); + const leaderWasReused = Boolean(currentLeader) && + (!leaderStart || !currentLeader.startIdentity || currentLeader.startIdentity !== leaderStart); + const originalGroupAnchored = !leaderWasReused && snapshot.some((item) => { + if (item.processGroupId !== child.pid || !item.startIdentity) return false; + if (item.pid === child.pid) return Boolean(leaderStart) && item.startIdentity === leaderStart; + const expected = knownStarts.get(item.pid); + return treeState.knownPids.has(item.pid) && Boolean(expected) && item.startIdentity === expected; + }); + return snapshot.some((item) => { + if (!isLiveState(item.state)) return false; + if (item.processGroupId === child.pid && originalGroupAnchored) return true; + if (!treeState.knownPids.has(item.pid)) return false; + const expected = knownStarts.get(item.pid); + return !expected || !item.startIdentity || expected === item.startIdentity; + }); + }; + if (processes === null) return true; + if (processes !== null) { + if (snapshotHasTrackedLive(processes)) return true; + // A detached child can inherit the run marker slightly after its very + // short-lived parent disappears from /proc. Observe for a bounded grace + // instead of making one empty scan authoritative and releasing the lock. + if (platform === "linux" && treeState.runMarker && + !processes.some((item) => item.pid === child.pid) && + treeState.markerObservationComplete !== true) { + const configuredGrace = Number(treeState.markerObservationGraceMs); + const observationGraceMs = Number.isFinite(configuredGrace) && configuredGrace >= 0 + ? configuredGrace + : MARKER_OBSERVATION_GRACE_MS; + const observationDeadline = Date.now() + observationGraceMs; + while (Date.now() < observationDeadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + processes = await refreshProcessTree(child, treeState, { + platform, procRoot, fsOps, posixProcessSnapshot, runUtility: run, + }); + if (treeState.processIdentityUncertain === true) return true; + if (processes === null) break; + if (snapshotHasTrackedLive(processes)) return true; + } + treeState.markerObservationComplete = true; + } + if (processes !== null && ignoreZombieOnly && !processes.incomplete) { + return treeState.processIdentityUncertain === true ? true : false; + } + } + try { + probeProcessGroup(child.pid); + } catch (error) { + // Only ESRCH is a reliable negative result. Permission and unexpected + // probe errors fail safe so callers quarantine rather than reuse a live + // workspace. + if (treeState.processIdentityUncertain === true) return true; + return error.code !== "ESRCH"; + } + if (ignoreZombieOnly && platform === "linux") { + const classification = await linuxProcessGroupHasLiveMembers(child.pid, procRoot, fsOps); + return classification !== false; + } + return true; +} + +export async function signalProcessTree(child, signal, treeState, { + platform = process.platform, + posixProcessSnapshot: snapshot = posixProcessSnapshot, + runUtility: run = runUtility, + windowsSnapshot, + queryRootIdentity = windowsProcessStartIdentity, + taskkill = async (pid) => { + const result = await run("taskkill.exe", ["/PID", String(pid), "/F"]); + if (result.exitCode !== 0) throw new Error(result.stderr.trim() || "taskkill failed"); + }, + taskkillTree = async (pid) => { + const result = await run("taskkill.exe", ["/PID", String(pid), "/T", "/F"]); + if (result.exitCode !== 0) throw new Error(result.stderr.trim() || "taskkill /T failed"); + }, + killOne = (pid, sig) => process.kill(pid, sig), + killGroup = (pgid, sig) => process.kill(-pgid, sig), +} = {}) { + if (!Number.isInteger(child.pid)) return; + if (platform === "win32") { + await treeState.initialRefresh; + const expectedRootIdentity = treeState.knownStarts?.get(child.pid); + const rootHandleLive = child.exitCode === null && child.signalCode === null; + if (rootHandleLive && !expectedRootIdentity) { + throw new Error("cannot terminate Windows process tree: worker creation identity is unavailable"); + } + if (rootHandleLive) { + const observedRootIdentity = await queryRootIdentity(child.pid, run); + if (observedRootIdentity !== expectedRootIdentity) { + throw new Error("cannot terminate Windows process tree: worker PID creation identity changed"); + } + if (child.exitCode === null && child.signalCode === null) { + await taskkillTree(child.pid); + } + } + const killVerified = async (pids) => { + const order = [ + ...pids.filter((pid) => pid !== child.pid).reverse(), + ...(pids.includes(child.pid) ? [child.pid] : []), + ]; + for (const pid of order) { + const expected = treeState.knownStarts.get(pid); + let current; + try { current = await queryRootIdentity(pid, run); } + catch { continue; } + if (!expected || current !== expected) continue; + await taskkill(pid); + } + }; + let remaining = await windowsProcessTreePids(child.pid, treeState, { + runUtility: run, windowsSnapshot, + }); + await killVerified(remaining); + remaining = await windowsProcessTreePids(child.pid, treeState, { + runUtility: run, windowsSnapshot, + }); + await killVerified(remaining); + return; + } + await refreshProcessTree(child, treeState, { + platform, posixProcessSnapshot: snapshot, runUtility: run, + }); + const processes = await snapshot(); + const byPid = processes === null ? new Map() : new Map(processes.map((item) => [item.pid, item])); + // Signal the process group only while it is still provably ours: the leader + // must be alive, still lead the group, and match its recorded start identity. + // Otherwise the PGID may have been recycled onto an unrelated group. When + // enumeration itself is unavailable (restricted /proc, failing ps) identity + // cannot be verified either way, so containment wins: signal the group rather + // than leave a possibly-live worker running through both grace periods. + const leaderStart = treeState.knownStarts?.get(child.pid); + const currentLeader = processes === null ? null : byPid.get(child.pid); + const leaderWasReused = Boolean(currentLeader) && + (!leaderStart || !currentLeader.startIdentity || currentLeader.startIdentity !== leaderStart); + const groupIsOriginal = processes !== null && !leaderWasReused && processes.some((item) => { + if (item.processGroupId !== child.pid || !item.startIdentity) return false; + if (item.pid === child.pid) { + return Boolean(leaderStart) && item.startIdentity === leaderStart; + } + const expected = treeState.knownStarts?.get(item.pid); + return treeState.knownPids.has(item.pid) && Boolean(expected) && item.startIdentity === expected; + }); + if (groupIsOriginal || processes === null) { + try { + killGroup(child.pid, signal); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } + } + for (const pid of [...treeState.knownPids].reverse()) { + if (pid === child.pid) continue; + const item = byPid.get(pid); + const expected = treeState.knownStarts?.get(pid); + // A successful snapshot proves an absent PID has exited. Never signal its + // numeric value after the enumeration, where it could already be reused. + if (processes !== null && !item) continue; + if (processes !== null && (!item || !expected || !item.startIdentity || + expected !== item.startIdentity)) continue; + try { killOne(pid, signal); } + catch (error) { if (error.code !== "ESRCH") throw error; } + } +} + +export async function waitForProcessTreeExit(child, timeoutMs, treeState, options = {}) { + const deadline = Date.now() + timeoutMs; + while (await isProcessTreeAlive(child, treeState, options)) { + if (Date.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return true; +} + +export async function waitForChildExit(child, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (child.exitCode === null && child.signalCode === null) { + if (Date.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return true; +} diff --git a/plugins/Hylouis233/cli-agent-bridge/ps1-json-runner.ps1 b/plugins/Hylouis233/cli-agent-bridge/ps1-json-runner.ps1 new file mode 100644 index 0000000..f5eb405 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/ps1-json-runner.ps1 @@ -0,0 +1,49 @@ +$previousErrorActionPreference = $ErrorActionPreference +$ErrorActionPreference = "Stop" +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +[Console]::InputEncoding = $utf8NoBom +[Console]::OutputEncoding = $utf8NoBom +$OutputEncoding = $utf8NoBom + +try { + $payload = [Console]::In.ReadToEnd() | ConvertFrom-Json -ErrorAction Stop + if ($null -eq $payload -or [string]::IsNullOrWhiteSpace([string]$payload.command)) { + throw "invalid PowerShell runner payload: command is required" + } + if ($null -ne $payload.stdinText) { + throw "PowerShell shim fallback does not support backend stdin" + } + [string[]]$backendArgs = @() + if ($null -ne $payload.args) { + $backendArgs = [string[]]@($payload.args | ForEach-Object { [string]$_ }) + } + $resolved = Get-Command -Name ([string]$payload.command) ` + -CommandType Application, ExternalScript -ErrorAction Stop + $backendSource = $resolved.Source + $extension = [IO.Path]::GetExtension($backendSource) + if ($extension -ieq ".cmd" -or $extension -ieq ".bat") { + throw ".cmd/.bat backends must use the native command-processor launcher" + } + $global:LASTEXITCODE = $null + # A backend's stderr and exit semantics are data. Do not leak the runner's + # fail-fast preference into a PowerShell backend and turn Write-Error into + # a terminating runner exception. + $ErrorActionPreference = $previousErrorActionPreference + & $backendSource @backendArgs + $scriptSucceeded = $? + $scriptExitCode = $LASTEXITCODE + # Reaching this point means the PowerShell script completed normally. + # LASTEXITCODE may be stale from a native command that the script handled; + # only use it when the script invocation itself reported failure. An + # explicit `exit N` produces that pair, while a handled native failure + # followed by successful script work leaves scriptSucceeded true. `throw` + # reaches the catch below. + if (-not $scriptSucceeded -and $null -ne $scriptExitCode) { + exit [int]$scriptExitCode + } + exit 0 +} +catch { + [Console]::Error.WriteLine($_.Exception.GetBaseException().Message) + exit 127 +} diff --git a/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 b/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 new file mode 100644 index 0000000..4e892da --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 @@ -0,0 +1,28 @@ +# No param block: with -File, every token after the script path lands in +# $args as a literal string, so dashes, quotes, parentheses, and percent +# signs survive verbatim. +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +[Console]::InputEncoding = $utf8NoBom +[Console]::OutputEncoding = $utf8NoBom +$OutputEncoding = $utf8NoBom +$Command = $args[0] +[string[]]$rest = @($args | Select-Object -Skip 1) +if ([string]::IsNullOrWhiteSpace($Command)) { + [Console]::Error.WriteLine("backend command is missing") + exit 127 +} + +try { + $resolved = Get-Command -Name $Command -CommandType Application, ExternalScript -ErrorAction Stop + $global:LASTEXITCODE = $null + & $resolved.Source @rest + $succeeded = $? + $nativeExitCode = $LASTEXITCODE + if ($null -ne $nativeExitCode) { exit [int]$nativeExitCode } + if (-not $succeeded) { exit 1 } + exit 0 +} +catch { + [Console]::Error.WriteLine($_.Exception.Message) + exit 127 +} diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs new file mode 100644 index 0000000..fdda0ce --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -0,0 +1,3697 @@ +#!/usr/bin/env node +// cli-agent-bridge: a dependency-free stdio MCP server that lets MiniMax Code +// delegate coding tasks to locally installed coding CLIs (Claude Code, Codex, +// Kimi Code, ZCode, DSH). The server makes no network calls of its own; each +// backend CLI runs headless with the local user authentication. +// +// License: MIT. See NOTICE for upstream credits. + +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import { chmod, lstat, mkdir, mkdtemp, open, readFile, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +import { + resolvePathCommand, safeGitInvocation, subscribePathCommand, subscribeTrustedGitExecutable, +} from "./git-executable.mjs"; +import { initializeProcessTree, isProcessTreeAlive, refreshProcessTree, signalProcessTree, waitForChildExit, waitForProcessTreeExit } from "./process-tree.mjs"; +import { + acquireGitWorkspaceLock, + WORKSPACE_LOCK_REF_PREFIX, + WorkspaceLockCancelledError, + WorkspaceLockDeadlineError, +} from "./workspace-lock.mjs"; + +const SERVER_NAME = "cli-agent-bridge"; +const SERVER_VERSION = "0.1.0"; +const PROTOCOL_VERSION = "2025-06-18"; +const DEFAULT_TIMEOUT_MS = 1_200_000; +const MIN_TIMEOUT_MS = 5_000; +const MAX_TIMEOUT_MS = 3_600_000; +const VERSION_CHECK_TIMEOUT_MS = 15_000; +const BACKEND_CONFIG_READ_TIMEOUT_MS = 15_000; +const GIT_TIMEOUT_MS = 30_000; +const TEST_KILL_GRACE_MS = process.env.NODE_ENV === "test" + ? Number(process.env.CLI_AGENT_BRIDGE_TEST_KILL_GRACE_MS) + : NaN; +const KILL_GRACE_MS = Number.isInteger(TEST_KILL_GRACE_MS) && TEST_KILL_GRACE_MS >= 50 + ? Math.min(10_000, TEST_KILL_GRACE_MS) + : 10_000; +const MAX_CAPTURE_CHARS = 5_000_000; +const MAX_TRACE_EVENT_CHARS = 256_000; +const TRACE_PARSE_YIELD_CHARS = 64 * 1024; +const TRACE_CLEANUP_TIMEOUT_MS = 1_000; +const backgroundFinalizers = new Set(); +let quarantineReadTestCallCount = 0; +const RAW_TAIL_CHARS = 60_000; +const FETCH_PROVENANCE_TIPS = Symbol("fetchProvenanceTips"); +const LOCK_HISTORY_REFS = Symbol("lockHistoryRefs"); +const MAX_JSON_RPC_LINE_CHARS = 1_000_000; +const QUARANTINE_RECORD_FILE = "record.json"; +const WORKSPACE_QUARANTINE_DIRECTORY = "cli-agent-bridge-quarantines"; +const TEST_RUNTIME_PLATFORM = process.env.NODE_ENV === "test" + ? process.env.CLI_AGENT_BRIDGE_TEST_PLATFORM + : ""; +const RUNTIME_PLATFORM = ["darwin", "freebsd", "linux", "win32"].includes(TEST_RUNTIME_PLATFORM) + ? TEST_RUNTIME_PLATFORM + : process.platform; +// Deliberate double gate: NODE_ENV=test alone never enables an unsupported +// production platform, and the explicit flag is honored only by test builds. +const PROCESS_TREE_TEST_MODE = process.env.NODE_ENV === "test" && + process.env.CLI_AGENT_BRIDGE_TEST_PROCESS_TREE_MODE === "1"; + +function supportsReliableProcessContainment(platform = RUNTIME_PLATFORM) { + return platform === "win32" || PROCESS_TREE_TEST_MODE; +} + +function unsupportedPlatformMessage(operation) { + return operation + " is unsupported on " + RUNTIME_PLATFORM + + ": reliable worker and Git-helper lifecycle containment is available only on Windows"; +} + +function trustedWindowsPowerShell() { + const windowsRoot = process.env.SystemRoot ?? process.env.WINDIR ?? ""; + if (!path.win32.isAbsolute(windowsRoot)) { + throw new Error("cannot locate the trusted Windows PowerShell executable"); + } + return path.win32.join( + windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe", + ); +} + +// Built-in defaults. A missing or invalid bundled sibling backends.json falls +// back to this table. An explicitly configured CLI_AGENT_BRIDGE_BACKENDS path +// is authoritative and fails closed on every load or validation error. +const FALLBACK_BACKENDS = { + claude: { + label: "Claude Code", + command: "claude", + buildArgs: ["-p", "", "--output-format", "text", "--permission-mode", "acceptEdits"], + resumeArgs: ["-p", "", "--output-format", "text", "--permission-mode", "acceptEdits", "--resume", ""], + experimental: false, + }, + codex: { + label: "OpenAI Codex CLI", + command: "codex", + buildArgs: ["exec", "--", ""], + resumeArgs: ["exec", "resume", "", "--", ""], + experimental: false, + }, + kimi: { + label: "Kimi Code", + command: "kimi", + buildArgs: ["-p", ""], + resumeArgs: ["-S", "", "-p", ""], + experimental: false, + }, + zcode: { + label: "ZCode", + command: "zcode", + buildArgs: ["-p", ""], + resumeArgs: null, + experimental: true, + notes: "Desktop ZCode builds have no verified headless mode; set command to your CLI if your distribution provides one.", + }, + dsh: { + label: "DeepSeek Harness (dsh)", + command: "dsh", + buildArgs: ["--profile", "headless", ""], + resumeArgs: null, + experimental: true, + notes: "Uses the documented headless profile; requires a headless profile under DSH_HOME/profiles.", + }, +}; + +const TOOLS = [ + { + name: "list_backends", + title: "List Delegation Backends", + description: + "List the configured coding-CLI backends (claude, codex, kimi, zcode, dsh) and report which ones are installed and available on this machine. Read-only.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, + { + name: "workspace_status", + title: "Workspace Git Status", + description: + "Return git status, diff stat, and changed files for a workspace before delegating work. Does not change the worktree, but acquires and releases hidden Git-ref lock metadata while snapshotting.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + workspacePath: { + type: "string", + minLength: 1, + description: "Absolute or resolvable path to the target git repository.", + }, + }, + required: ["workspacePath"], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + { + name: "delegate_task", + title: "Delegate Task To A Coding CLI", + description: + "Run a coding task with a locally installed coding CLI (backend: claude, codex, kimi, zcode, or dsh) inside the given workspace, headless. Returns the CLI exit code, readable output tail, stderr tail, and the git snapshot (staged, unstaged, untracked, and committed deltas) produced by the run. Refuses to run when the working tree is dirty unless allowDirty=true. Paths that resolve to the same canonical Git worktree are serialized across bridge processes.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + backend: { + type: "string", + minLength: 1, + description: "Backend name as listed by list_backends (claude, codex, kimi, zcode, dsh).", + }, + task: { + type: "string", + minLength: 1, + description: "Self-contained task to hand to the backend CLI. Include file paths and acceptance criteria; never include credentials.", + }, + workspacePath: { + type: "string", + minLength: 1, + description: "Absolute or resolvable path to the target git repository.", + }, + allowDirty: { + type: "boolean", + default: false, + description: "When false (default), refuse to run if git status --short is non-empty.", + }, + resumeSessionId: { + type: "string", + minLength: 1, + description: "Optional existing session id to resume in the backend CLI (where the backend template supports it).", + }, + timeoutMs: { + type: "integer", + minimum: MIN_TIMEOUT_MS, + maximum: MAX_TIMEOUT_MS, + default: DEFAULT_TIMEOUT_MS, + description: "Overall deadline in milliseconds, including workspace lock acquisition, preflight Git checks, the worker, and post-run snapshots. Defaults to 1200000 (20 minutes). Confirming safe process-tree termination may extend beyond the deadline by the kill grace period.", + }, + }, + required: ["backend", "task", "workspacePath"], + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, + }, +]; + +function validateBackendConfiguration(parsed, file) { + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || + !parsed.backends || typeof parsed.backends !== "object" || Array.isArray(parsed.backends)) { + throw new Error("backend configuration must contain a backends object"); + } + const entries = Object.entries(parsed.backends); + if (entries.length === 0) throw new Error("backend configuration backends object is empty"); + const configDirectory = path.dirname(file); + return Object.fromEntries(entries.map(([name, spec]) => { + if (!name.trim() || !spec || typeof spec !== "object" || Array.isArray(spec)) { + throw new Error("backend configuration entry " + JSON.stringify(name) + " is invalid"); + } + if (typeof spec.command !== "string" || !spec.command.trim()) { + throw new Error("backend configuration entry " + JSON.stringify(name) + " has no command"); + } + if (!Array.isArray(spec.buildArgs) || !spec.buildArgs.every((arg) => typeof arg === "string")) { + throw new Error("backend configuration entry " + JSON.stringify(name) + " has invalid buildArgs"); + } + if (spec.resumeArgs !== undefined && spec.resumeArgs !== null && + (!Array.isArray(spec.resumeArgs) || !spec.resumeArgs.every((arg) => typeof arg === "string"))) { + throw new Error("backend configuration entry " + JSON.stringify(name) + " has invalid resumeArgs"); + } + return [name, /[\\/]/u.test(spec.command) + ? { ...spec, command: path.resolve(configDirectory, spec.command) } + : spec]; + })); +} + +function trackBackgroundFinalizer(promise) { + const finalizer = Promise.resolve(promise).catch(() => {}); + backgroundFinalizers.add(finalizer); + void finalizer.finally(() => { backgroundFinalizers.delete(finalizer); }); + return finalizer; +} + +export function backendConfigurationReaderEnvironment(source = process.env) { + const env = { LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }; + // Node's Windows spawn path requires PATHEXT even for the absolute .exe + // handed to the Job runner. Use a fixed native-only value, never caller + // input that could add script shims. + if (process.platform === "win32") { + env.PATHEXT = ".COM;.EXE;.BAT;.CMD"; + // Node/libuv restores several omitted Windows environment variables from + // the parent. Explicit empty tombstones prevent ambient search paths and + // account metadata from reappearing in the managed reader process. + for (const name of [ + "PATH", "PSModulePath", "HOMEDRIVE", "HOMEPATH", "LOGONSERVER", "SYSTEMDRIVE", + "USERDOMAIN", "USERNAME", "USERPROFILE", + ]) env[name] = ""; + } + const entries = Object.entries(source); + for (const canonicalName of ["SystemRoot", "WINDIR", "TEMP", "TMP"]) { + const entry = entries.find(([name, value]) => + name.toLowerCase() === canonicalName.toLowerCase() && typeof value === "string" && value, + ); + if (entry) env[canonicalName] = entry[1]; + } + // Test-only synchronization stays explicit; no caller credentials, Git + // routing, loader injection, or user PATH are inherited by the helper. + if (source.NODE_ENV === "test") { + env.NODE_ENV = "test"; + if (typeof source.CLI_AGENT_BRIDGE_TEST_BACKEND_CONFIG_READ_STALL_FILE === "string" && + source.CLI_AGENT_BRIDGE_TEST_BACKEND_CONFIG_READ_STALL_FILE) { + env.CLI_AGENT_BRIDGE_TEST_BACKEND_CONFIG_READ_STALL_FILE = + source.CLI_AGENT_BRIDGE_TEST_BACKEND_CONFIG_READ_STALL_FILE; + } + } + return env; +} + +export function backendConfigurationControlEnvironment(source = process.env) { + const env = backendConfigurationReaderEnvironment(source); + if (process.platform !== "win32") return env; + const windowsRoot = env.SystemRoot ?? env.WINDIR; + if (typeof windowsRoot === "string" && path.win32.isAbsolute(windowsRoot)) { + env.PSModulePath = path.win32.join( + windowsRoot, "System32", "WindowsPowerShell", "v1.0", "Modules", + ); + } + const profile = Object.entries(source).find(([name, value]) => + name.toLowerCase() === "userprofile" && typeof value === "string" && + path.win32.isAbsolute(value), + ); + if (profile) env.USERPROFILE = profile[1]; + return env; +} + +async function readBackendConfiguration(file, options = {}) { + let raw; + if (options.readFile) { + raw = await interruptibleFilesystemOperation( + () => options.readFile(file, "utf8"), options, + ); + } else if (process.platform === "win32" || (process.env.NODE_ENV === "test" && + process.env.CLI_AGENT_BRIDGE_TEST_BACKEND_CONFIG_READ_STALL_FILE)) { + await interruptibleFilesystemOperation(() => Promise.resolve(), options); + const remaining = options.deadline === null || options.deadline === undefined + ? BACKEND_CONFIG_READ_TIMEOUT_MS + : options.deadline - Date.now(); + if (remaining <= 0) throw new DeadlineExceededError("delegation deadline exceeded"); + let controller = null; + const commandRunner = options.commandRunner ?? runCommand; + const readerEnvironment = backendConfigurationReaderEnvironment(); + const result = await commandRunner(process.execPath, [ + path.join(path.dirname(fileURLToPath(import.meta.url)), "config-reader.mjs"), file, + ], { + env: backendConfigurationControlEnvironment(), + exactEnvironment: readerEnvironment, + forwardExactEnvironment: true, + timeoutMs: Math.min(BACKEND_CONFIG_READ_TIMEOUT_MS, remaining), + manageProcessTree: true, + shouldCancel: () => Boolean(options.cancel?.cancelled), + onChild: (current) => { + controller = current; + if (options.cancel) options.cancel.controller = current; + }, + }); + if (options.cancel?.controller === controller) options.cancel.controller = null; + if (result.treeTerminated !== true) { + throw new BackendConfigurationCleanupError( + "backend configuration reader cleanup could not be confirmed: " + + (result.terminationError || "process tree termination was unconfirmed"), + ); + } + if (options.cancel?.cancelled) { + throw new OperationCancelledError("operation cancelled by client"); + } + if (options.deadline !== null && options.deadline !== undefined && + Date.now() >= options.deadline) { + throw new DeadlineExceededError("delegation deadline exceeded"); + } + if (result.timedOut) throw new Error("backend configuration read timed out"); + if (result.exitCode !== 0) { + throw new Error(result.stderr.trim() || "backend configuration reader failed"); + } + if (result.stdoutTruncated) throw new Error("backend configuration exceeds the capture limit"); + raw = result.stdout; + } else { + raw = await interruptibleFilesystemOperation(() => readFile(file, "utf8"), options); + } + return validateBackendConfiguration(JSON.parse(raw), file); +} + +export async function loadBackends(options = {}) { + const hasOverride = Object.prototype.hasOwnProperty.call( + process.env, "CLI_AGENT_BRIDGE_BACKENDS", + ); + if (hasOverride) { + const override = process.env.CLI_AGENT_BRIDGE_BACKENDS; + if (typeof override !== "string" || !override.trim()) { + throw new Error("explicit backend configuration path is empty"); + } + const file = path.resolve(override); + try { + return await readBackendConfiguration(file, options); + } catch (error) { + if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError || + error instanceof BackendConfigurationCleanupError) { + throw error; + } + throw new Error("cannot load explicit backend configuration " + file + ": " + error.message); + } + } + const bundled = path.join(path.dirname(fileURLToPath(import.meta.url)), "backends.json"); + try { + return await readBackendConfiguration(bundled, options); + } catch (error) { + if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError || + error instanceof BackendConfigurationCleanupError) { + throw error; + } + return FALLBACK_BACKENDS; + } +} + +function substituteArgs(template, task, session) { + return template.map((arg) => { + let out = arg; + if (typeof session === "string" && session.trim()) out = out.replaceAll("", session.trim()); + return out.replaceAll("", task); + }); +} + +// Bounded capture: chunks are kept in a ring buffer with a running length, so a +// runaway CLI never triggers repeated multi-megabyte string copies. +function capture(binary = false) { + let chunks = []; + let length = 0; + let truncated = false; + return { + push(chunk) { + if (binary ? !Buffer.isBuffer(chunk) : typeof chunk !== "string") return; + if (chunk.length === 0) return; + chunks.push(chunk); + length += chunk.length; + while (length > MAX_CAPTURE_CHARS && chunks.length > 0) { + const dropped = chunks.shift(); + length -= dropped.length; + truncated = true; + } + }, + value() { return binary ? Buffer.concat(chunks, length) : chunks.join(""); }, + truncated() { return truncated; }, + }; +} + +export async function runCommand(command, args, options = {}) { + const spawnOnce = (argv, shellArgs) => new Promise((resolve) => { + const binaryStdout = options.binaryStdout === true; + if (typeof options.shouldCancel === "function" && options.shouldCancel()) { + resolve({ + stdout: binaryStdout ? Buffer.alloc(0) : "", stderr: "", exitCode: null, timedOut: false, killed: false, + orphanedProcesses: false, treeTerminated: true, terminationError: "", + errorMessage: "command cancelled before spawn", spawnError: null, + stdoutTruncated: false, stderrTruncated: false, + }); + return; + } + const manageProcessTree = options.manageProcessTree === true; + const useWindowsJobRunner = manageProcessTree && process.platform === "win32" && !shellArgs && + options.processTreeTestMode !== true; + const useProcessTreeRunner = manageProcessTree && process.platform !== "win32" && !shellArgs; + const trackProcessTree = manageProcessTree && !useWindowsJobRunner; + const linuxRunMarker = manageProcessTree && process.platform === "linux" + ? randomUUID() + : null; + const baseEnvironment = options.env ?? process.env; + const childEnvironment = linuxRunMarker + ? { ...baseEnvironment, CLI_AGENT_BRIDGE_RUN_ID: linuxRunMarker } + : baseEnvironment; + const processTreeRunnerPayload = (useWindowsJobRunner || useProcessTreeRunner) ? JSON.stringify({ + command, + args: argv, + ...(options.stdinText === undefined ? {} : { stdinText: options.stdinText }), + ...(options.forwardExactEnvironment === true + ? { environment: options.exactEnvironment ?? childEnvironment } + : {}), + }) : ""; + const child = useWindowsJobRunner + ? spawn(trustedWindowsPowerShell(), [ + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", + options.windowsJobRunnerPath ?? + path.join(path.dirname(fileURLToPath(import.meta.url)), "windows-job-runner.ps1"), + "-NodeExecutable", process.execPath, + "-NodeRunner", path.join( + path.dirname(fileURLToPath(import.meta.url)), "process-tree-runner.mjs", + ), + ], { + cwd: options.cwd, + env: childEnvironment, + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + }) + : useProcessTreeRunner + ? spawn(process.execPath, [ + path.join(path.dirname(fileURLToPath(import.meta.url)), "process-tree-runner.mjs"), + ], { + cwd: options.cwd, + env: childEnvironment, + detached: process.platform !== "win32", + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + }) + : shellArgs + ? spawn(shellArgs[0], shellArgs.slice(1), { + cwd: options.cwd, + env: childEnvironment, + detached: manageProcessTree && process.platform !== "win32", + windowsHide: true, + stdio: [options.stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"], + }) + : spawn(command, argv, { + cwd: options.cwd, + env: childEnvironment, + detached: manageProcessTree && process.platform !== "win32", + windowsHide: true, + stdio: [options.stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"], + }); + let childSpawned = false; + child.once("spawn", () => { childSpawned = true; }); + // A containment/bootstrap process can fail before consuming a large + // payload. Its exit status/stderr carry the failure; absorb the pipe-side + // EPIPE so it cannot become an unhandled error that crashes the bridge. + child.stdin?.on("error", () => {}); + const stdoutBuf = capture(binaryStdout); + const stderrBuf = capture(); + let settled = false; + let settlingPromise = null; + let resolveStreamsClosed; + const streamsClosed = new Promise((resolve) => { resolveStreamsClosed = resolve; }); + let spawnError = null; + let timedOut = false; + let killed = false; + let orphanedProcesses = false; + let treeTerminated = true; + let terminationError = ""; + let exitCode = null; + let terminationCleanupPromise = null; + let terminationPromise = null; + let terminationReason = null; + let timer = null; + const timeoutMs = options.timeoutMs ?? 30_000; + const killGraceMs = options.killGraceMs ?? KILL_GRACE_MS; + const inspectionWaitMs = options.processTreeInspectionWaitMs ?? + Math.max(250, Math.min(killGraceMs, 2_000)); + const streamDrainMs = options.streamDrainMs ?? + Math.max(250, Math.min(killGraceMs, 2_000)); + const waitBounded = async (promise, waitMs) => { + let waitTimer; + const completed = await Promise.race([ + Promise.resolve(promise).then(() => true), + new Promise((resolveWait) => { waitTimer = setTimeout(() => resolveWait(false), waitMs); }), + ]); + clearTimeout(waitTimer); + return completed; + }; + const treeState = { + knownPids: new Set(Number.isInteger(child.pid) ? [child.pid] : []), + knownStarts: new Map(), + runMarker: linuxRunMarker, + markerObservationGraceMs: options.markerObservationGraceMs, + }; + let treeRefreshPromise = null; + let treeRefreshTimer = null; + let treeRefreshStopped = false; + const refreshTree = () => { + if (!trackProcessTree || treeRefreshStopped) return Promise.resolve(); + if (treeRefreshPromise) return treeRefreshPromise; + const refresh = options.refreshProcessTree ?? refreshProcessTree; + treeRefreshPromise = Promise.resolve().then(() => refresh(child, treeState)) + .then((snapshot) => { + if (process.platform !== "win32" && snapshot === null) { + treeState.processInspectionUncertain = true; + treeTerminated = false; + terminationError ||= "process-tree refresh returned an incomplete snapshot"; + } + }) + .catch((error) => { + treeState.processInspectionUncertain = true; + treeTerminated = false; + terminationError ||= "process-tree inspection failed: " + error.message; + }) + .finally(() => { treeRefreshPromise = null; }); + return treeRefreshPromise; + }; + const stopTreeRefresh = async () => { + treeRefreshStopped = true; + if (treeRefreshTimer) { + clearInterval(treeRefreshTimer); + treeRefreshTimer = null; + } + if (!await waitBounded(treeState.initialRefresh, inspectionWaitMs)) { + treeState.processInspectionUncertain = true; + treeTerminated = false; + terminationError ||= "process-tree initialization did not finish before cleanup"; + } + const inFlightRefresh = treeRefreshPromise; + if (inFlightRefresh && !await waitBounded(inFlightRefresh, inspectionWaitMs)) { + treeState.processInspectionUncertain = true; + treeTerminated = false; + terminationError ||= "process-tree refresh did not finish before cleanup"; + } + }; + const settle = () => { + if (settled) return Promise.resolve(); + if (settlingPromise) return settlingPromise; + settlingPromise = (async () => { + // `exit` identifies backend/supervisor termination and starts cleanup. + // `close` is only the stdout/stderr drain barrier; an escaped process + // retaining those handles must not postpone tree inspection until the + // overall command timeout. + if (!await waitBounded(streamsClosed, streamDrainMs)) { + treeTerminated = false; + terminationError ||= "backend output streams did not close after cleanup"; + child.stdout?.destroy(); + child.stderr?.destroy(); + } + if (terminationCleanupPromise) await terminationCleanupPromise; + if (settled) return; + settled = true; + treeRefreshStopped = true; + clearTimeout(timer); + if (treeRefreshTimer) clearInterval(treeRefreshTimer); + resolve({ + stdout: stdoutBuf.value(), + stderr: stderrBuf.value(), + exitCode, + timedOut, + killed, + orphanedProcesses, + treeTerminated, + terminationError, + errorMessage: "", + spawnError, + stdoutTruncated: stdoutBuf.truncated(), + stderrTruncated: stderrBuf.truncated(), + }); + })(); + return settlingPromise; + }; + + const terminate = (reason) => { + if (settled) return Promise.resolve(); + if (terminationPromise) return terminationPromise; + terminationReason = reason; + if (terminationReason === "timeout") timedOut = true; + if (terminationReason === "orphaned") orphanedProcesses = true; + terminationCleanupPromise = Promise.resolve().then(async () => { + if (trackProcessTree) await stopTreeRefresh(); + const signalTree = options.signalProcessTree ?? signalProcessTree; + const waitForTreeExit = options.waitForProcessTreeExit ?? waitForProcessTreeExit; + if (trackProcessTree) await signalTree(child, "SIGTERM", treeState); + else try { child.kill("SIGTERM"); } catch { /* already gone */ } + const exited = trackProcessTree + ? await waitForTreeExit(child, killGraceMs, treeState) + : await waitForChildExit(child, killGraceMs); + if (!exited) { + killed = true; + if (trackProcessTree) await signalTree(child, "SIGKILL", treeState); + else try { child.kill("SIGKILL"); } catch { /* already gone */ } + treeTerminated = trackProcessTree + ? await waitForTreeExit(child, killGraceMs, treeState, { ignoreZombieOnly: true }) + : await waitForChildExit(child, killGraceMs); + if (!treeTerminated) { + terminationError ||= "process tree still appears alive after forceful termination"; + } + } + if (treeState.processInspectionUncertain === true) treeTerminated = false; + }).catch((error) => { + treeTerminated = false; + terminationError ||= error.message; + }); + terminationPromise = terminationCleanupPromise.then(() => settle()); + return terminationPromise; + }; + + if (trackProcessTree) { + // Capture the root's start identity immediately so termination can later + // detect a reused PID. POSIX keeps polling to track descendants that + // escape the process group. + // Linux follows only tracked /proc task children, so a short interval + // catches session escapes without scanning the host process table. + const initializeTree = options.initializeProcessTree ?? initializeProcessTree; + treeState.initialRefresh = Promise.resolve().then(() => initializeTree(child, treeState)).catch((error) => { + treeState.processInspectionUncertain = true; + treeTerminated = false; + terminationError ||= "process-tree initialization failed: " + error.message; + try { child.kill("SIGKILL"); } catch { /* already gone */ } + }); + if (process.platform !== "win32" || options.refreshProcessTree) { + // Initialization captures the immutable root identity and mutates the + // same tracking state as a periodic refresh. Do not let the timer race + // that startup barrier or revive polling after startup failed/cleanup + // already stopped the tree tracker. + void treeState.initialRefresh.then(() => { + if (treeRefreshStopped || terminationPromise || !treeTerminated || + treeState.processInspectionUncertain === true || + child.exitCode !== null || child.signalCode !== null) return; + const refreshIntervalMs = process.platform === "linux" && + treeState.linuxTaskChildrenUnavailable !== true ? 25 : 250; + treeRefreshTimer = setInterval(() => { void refreshTree(); }, refreshIntervalMs); + treeRefreshTimer.unref?.(); + }); + } + } + // Publish the controller only after the startup identity barrier exists. + // A synchronous cancellation callback can then terminate immediately + // without racing signal delivery ahead of initialization. + if (typeof options.onChild === "function" && Number.isInteger(child.pid)) { + options.onChild({ child, terminate }); + } + if (typeof options.shouldCancel === "function" && options.shouldCancel()) { + void terminate("cancelled"); + } + if (child.stdin) { + if (useWindowsJobRunner) { + // The PowerShell runner establishes and joins a kill-on-close Job + // before reading stdin, so sending the payload cannot race containment. + if (!terminationPromise) child.stdin.end(processTreeRunnerPayload); + } else if (useProcessTreeRunner) { + void treeState.initialRefresh.then(() => { + if (!treeTerminated || terminationPromise || treeRefreshStopped || + child.exitCode !== null || child.signalCode !== null || + (typeof options.shouldCancel === "function" && options.shouldCancel())) return; + child.stdin.end(processTreeRunnerPayload); + }); + } else { + child.stdin.end(options.stdinText); + } + } + + timer = setTimeout(() => { void terminate("timeout"); }, timeoutMs); + + if (!binaryStdout) child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdoutBuf.push(chunk); }); + child.stderr.on("data", (chunk) => { stderrBuf.push(chunk); }); + + child.on("error", (error) => { + if (settled) return; + if (childSpawned) { + treeTerminated = false; + terminationError ||= "backend process error after spawn: " + error.message; + void terminate("process-error"); + return; + } + spawnError = error; + settled = true; + treeRefreshStopped = true; + clearTimeout(timer); + if (treeRefreshTimer) clearInterval(treeRefreshTimer); + resolve({ + stdout: stdoutBuf.value(), + stderr: stderrBuf.value(), + exitCode: null, + timedOut, + killed, + orphanedProcesses, + treeTerminated, + terminationError, + errorMessage: error.message, + spawnError, + stdoutTruncated: stdoutBuf.truncated(), + stderrTruncated: stderrBuf.truncated(), + }); + }); + child.on("exit", (code) => { + if (settled) return; + exitCode = code; + if (terminationPromise) return; + void (async () => { + try { + if (trackProcessTree) await stopTreeRefresh(); + if (terminationPromise) { + await terminationPromise; + return; + } + const inspectTree = options.inspectProcessTree ?? isProcessTreeAlive; + if (trackProcessTree && treeState.processInspectionUncertain === true) { + await terminate("orphaned"); + return; + } + if (trackProcessTree && await inspectTree(child, treeState)) { + await terminate("orphaned"); + return; + } + if (terminationPromise) { + await terminationPromise; + return; + } + await settle(); + } catch (error) { + if (terminationPromise) { + await terminationPromise; + return; + } + // An inspection failure (for example WMI unavailable on Windows) must + // settle through the fail-closed path, not surface as an unhandled + // rejection that could take down the whole server. + treeTerminated = false; + terminationError = "process-tree inspection failed: " + error.message; + await settle(); + } + })(); + }); + child.on("close", () => { resolveStreamsClosed(); }); + }); + + const direct = await spawnOnce(args, null); + if (process.platform !== "win32" || !direct.spawnError) return direct; + // A managed Windows backend must never fall back to an uncontained process. + // The Job runner already resolves native executables and PowerShell/.cmd shims. + if (options.manageProcessTree === true) return direct; + if (typeof options.shouldCancel === "function" && options.shouldCancel()) return direct; + + // Windows shim fallback: .ps1/.cmd npm shims cannot be launched by CreateProcess, + // so retry through the bundled PowerShell runner, which forwards every argument + // verbatim (no cmd.exe re-interpretation). + const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "ps1-runner.ps1"); + return await spawnOnce(null, [ + trustedWindowsPowerShell(), + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + runner, + command, + ...args, + ]); +} + +function tail(text, count) { + return text.length > count ? text.slice(-count) : text; +} + +function interruptibleFilesystemOperation(operation, { cancel = null, deadline = null } = {}) { + if (cancel?.cancelled) return Promise.reject(new OperationCancelledError("operation cancelled by client")); + if (deadline !== null && Date.now() >= deadline) { + return Promise.reject(new DeadlineExceededError("delegation deadline exceeded")); + } + let pending; + try { + pending = typeof operation === "function" ? operation() : operation; + } catch (error) { + return Promise.reject(error); + } + return new Promise((resolve, reject) => { + let settled = false; + let timer = null; + let unsubscribe = () => {}; + const finish = (callback, value) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + unsubscribe(); + callback(value); + }; + const cancelled = () => finish(reject, new OperationCancelledError("operation cancelled by client")); + if (typeof cancel?.subscribe === "function") unsubscribe = cancel.subscribe(cancelled); + else if (cancel?.promise) void cancel.promise.then(cancelled); + if (deadline !== null) { + timer = setTimeout(() => finish( + reject, new DeadlineExceededError("delegation deadline exceeded"), + ), Math.max(0, deadline - Date.now())); + } + Promise.resolve(pending).then( + (value) => finish(resolve, value), + (error) => finish(reject, error), + ); + }); +} + +function waitForResolution(subscribe, options = {}) { + const { cancel = null, deadline = null } = options; + if (cancel?.cancelled) return Promise.reject(new OperationCancelledError("operation cancelled by client")); + if (deadline !== null && Date.now() >= deadline) { + return Promise.reject(new DeadlineExceededError("delegation deadline exceeded")); + } + return new Promise((resolve, reject) => { + let settled = false; + let timer = null; + let unsubscribeCancel = () => {}; + let unsubscribeResolution = () => {}; + const finish = (callback, value) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + unsubscribeCancel(); + unsubscribeResolution(); + callback(value); + }; + const cancelled = () => finish( + reject, new OperationCancelledError("operation cancelled by client"), + ); + if (typeof cancel?.subscribe === "function") unsubscribeCancel = cancel.subscribe(cancelled); + else if (cancel?.promise) void cancel.promise.then(cancelled); + if (deadline !== null) { + timer = setTimeout(() => finish( + reject, new DeadlineExceededError("delegation deadline exceeded"), + ), Math.max(0, deadline - Date.now())); + } + unsubscribeResolution = subscribe( + (value) => finish(resolve, value), + (error) => finish(reject, error), + ); + }); +} + +function resolveBackendCommand(command, options = {}) { + return waitForResolution( + (resolve, reject) => subscribePathCommand(command, resolve, reject), options, + ); +} + +function resolveTrustedGitExecutable(options = {}) { + return waitForResolution(subscribeTrustedGitExecutable, options); +} + +async function validateWorkspace(workspacePath, options = {}) { + if (typeof workspacePath !== "string" || !workspacePath.trim()) { + throw new InvalidArgumentsError("workspacePath must be a non-empty string"); + } + const resolved = path.resolve(workspacePath); + let stats; + try { + if (process.env.NODE_ENV === "test") { + const delayMs = Number(process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_VALIDATION_DELAY_MS ?? 0); + if (Number.isFinite(delayMs) && delayMs > 0) { + await interruptibleFilesystemOperation( + new Promise((resolve) => setTimeout(resolve, delayMs)), options, + ); + } + } + stats = await interruptibleFilesystemOperation(stat(resolved), options); + } catch (error) { + if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError) throw error; + throw new InvalidArgumentsError("workspacePath does not exist: " + resolved); + } + if (!stats.isDirectory()) { + throw new InvalidArgumentsError("workspacePath must be a directory: " + resolved); + } + try { + // Keep the execution directory bound to the directory validated here. A + // symlink supplied by the caller may be retargeted while the request waits + // for the repository lease, so it must not be resolved again at launch. + return await interruptibleFilesystemOperation(realpath(resolved), options); + } catch (error) { + if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError) throw error; + throw new InvalidArgumentsError("cannot canonicalize workspacePath: " + error.message); + } +} + +async function requireGitRepo(workspacePath, options = {}) { + const result = await runGitCommand(["rev-parse", "--is-inside-work-tree"], { + cwd: workspacePath, timeoutMs: 15_000, ...options, + }); + if (result.exitCode !== 0 || result.stdout.trim() !== "true") { + throw new Error("workspacePath is not a git repository: " + workspacePath); + } +} + +export async function gitWorktreeRoot(workspacePath, options = {}) { + const result = await runGitCommand(["rev-parse", "--show-toplevel"], { + cwd: workspacePath, ...options, + }); + const failure = snapshotFailure("git rev-parse --show-toplevel", result); + // Strip only Git's trailing line terminator: a legitimate directory name can + // end in whitespace, which .trim() would silently delete. + const output = result.stdout.replace(/\r?\n$/u, ""); + if (failure || !output) { + throw new Error("cannot identify Git worktree root: " + (failure || "empty output")); + } + try { + const canonicalize = options.fsOps?.realpath ?? realpath; + return await interruptibleFilesystemOperation(() => canonicalize(output), options); + } catch (error) { + if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError) throw error; + throw new Error("cannot canonicalize Git worktree root: " + error.message); + } +} + +export async function gitCommonDirectory(workspacePath, options = {}) { + const result = await runGitCommand(["rev-parse", "--git-common-dir"], { + cwd: workspacePath, ...options, + }); + const failure = snapshotFailure("git rev-parse --git-common-dir", result); + const output = result.stdout.replace(/\r?\n$/u, ""); + if (failure || !output) { + throw new Error("cannot identify Git common directory: " + (failure || "empty output")); + } + try { + const canonicalize = options.fsOps?.realpath ?? realpath; + return await interruptibleFilesystemOperation( + () => canonicalize(path.resolve(workspacePath, output)), options, + ); + } catch (error) { + if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError) throw error; + throw new Error("cannot canonicalize Git common directory: " + error.message); + } +} + +export function repositoryLockKey(gitCommonDir, repositoryId = null) { + if (repositoryId) { + return "git-common-dir-id:" + repositoryId; + } + const normalized = path.normalize(gitCommonDir); + // realpath() has already canonicalized ordinary aliases and path casing. + // Preserve the result: NTFS directories can opt into case sensitivity and + // may legally contain distinct repositories whose names differ only by case. + return "git-common-dir:" + normalized; +} + +async function openRepositoryAccess(gitCommonDir, options = {}) { + if (process.platform !== "linux") { + return { + commonDir: gitCommonDir, + key: repositoryLockKey(gitCommonDir), + close: async () => {}, + }; + } + const opening = open(gitCommonDir, "r"); + let handle; + try { + handle = await interruptibleFilesystemOperation(opening, options); + } catch (error) { + // Cancellation/deadline cannot cancel fs.open itself. Close a handle that + // arrives after the interrupt so the rename-stable directory pin cannot leak. + void opening.then((lateHandle) => lateHandle.close()).catch(() => {}); + throw error; + } + try { + const identity = await interruptibleFilesystemOperation(handle.stat({ bigint: true }), options); + if (!identity.isDirectory()) throw new Error("Git common directory is not a directory"); + const commonDir = "/proc/" + String(process.pid) + "/fd/" + String(handle.fd); + const observed = await interruptibleFilesystemOperation(stat(commonDir, { bigint: true }), options); + if (observed.dev !== identity.dev || observed.ino !== identity.ino) { + throw new Error("cannot establish a rename-stable Git common-directory handle"); + } + let closed = false; + return { + commonDir, + key: null, + async close() { + if (closed) return; + closed = true; + await handle.close(); + }, + }; + } catch (error) { + await handle.close().catch(() => {}); + throw error; + } +} + +const WORKSPACE_LOCK_STORE_NAME = "cli-agent-bridge-lock-store.git"; +const REPOSITORY_ID_FILE = "cli-agent-bridge-repository-id"; +const REPOSITORY_ID_REF = "refs/cli-agent-bridge/repository-id"; +const REPOSITORY_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + +function validateRepositoryId(value, source) { + const normalized = String(value ?? "").trim(); + if (!REPOSITORY_ID_PATTERN.test(normalized)) { + throw new Error("workspace lock store repository identity is malformed in " + source); + } + return normalized; +} + +async function readLegacyRepositoryId(idPath, options = {}) { + const metadata = await interruptibleFilesystemOperation(stat(idPath), options); + if (!metadata.isFile() || metadata.size > 128) { + throw new Error("workspace lock store repository identity is invalid"); + } + return validateRepositoryId( + await interruptibleFilesystemOperation(readFile(idPath, "utf8"), options), + "legacy identity file", + ); +} + +async function repositoryIdMode(storeRoot, options = {}) { + const result = await runGitCommand(["config", "--get", "core.sharedRepository"], { + cwd: storeRoot, ...options, + }); + if (result.exitCode === 1 && !result.stdout.trim() && !result.timedOut) return 0o600; + const failure = snapshotFailure("git config core.sharedRepository", result); + if (failure) throw new Error("cannot inspect lock-store sharing mode: " + failure); + const value = result.stdout.trim().toLowerCase(); + if (["all", "world", "everybody", "2"].includes(value)) return 0o664; + if (["group", "true", "1"].includes(value)) return 0o660; + if (/^0?[0-7]{3}$/u.test(value)) return Number.parseInt(value, 8) & 0o666; + return 0o600; +} + +async function readPublishedLegacyRepositoryId(idPath, options = {}) { + let lastError; + for (let attempt = 0; attempt < 40; attempt += 1) { + try { + return await readLegacyRepositoryId(idPath, options); + } catch (error) { + if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError) throw error; + lastError = error; + if (attempt + 1 < 40) { + await interruptibleFilesystemOperation( + new Promise((resolve) => setTimeout(resolve, 10)), options, + ); + } + } + } + throw lastError; +} + +async function publishLegacyRepositoryId(storeRoot, repositoryId, options = {}) { + const idPath = path.join(storeRoot, REPOSITORY_ID_FILE); + const mode = await repositoryIdMode(storeRoot, options); + const writing = writeFile(idPath, repositoryId + "\n", { flag: "wx", mode }); + try { + await interruptibleFilesystemOperation(writing, options); + await interruptibleFilesystemOperation(chmod(idPath, mode), options); + } catch (error) { + if (error.code !== "EEXIST") { + // Cancellation cannot abort the small exclusive write. Leave a completed + // identity as a safe compatibility anchor for the next request; a partial + // write is malformed and therefore fails closed in both bridge versions. + void writing.then(() => chmod(idPath, mode)).catch(() => {}); + throw error; + } + } + return await readPublishedLegacyRepositoryId(idPath, options); +} + +async function readRepositoryIdRef(storeRoot, options = {}) { + const refResult = await runGitCommand(["rev-parse", "--verify", "--quiet", REPOSITORY_ID_REF], { + cwd: storeRoot, ...options, + }); + if (refResult.exitCode === 1 && !refResult.stdout.trim() && !refResult.timedOut) return null; + const refFailure = snapshotFailure("git rev-parse repository identity ref", refResult); + if (refFailure) throw new Error("cannot read lock-store repository identity: " + refFailure); + const oid = refResult.stdout.trim(); + if (!/^[0-9a-f]{40,64}$/u.test(oid)) { + throw new Error("workspace lock store repository identity ref is invalid"); + } + const blobResult = await runGitCommand(["cat-file", "blob", oid], { + cwd: storeRoot, ...options, + }); + const blobFailure = snapshotFailure("git cat-file repository identity", blobResult); + if (blobFailure) throw new Error("cannot read lock-store repository identity: " + blobFailure); + return validateRepositoryId(blobResult.stdout, "repository identity ref"); +} + +async function publishRepositoryIdRef(storeRoot, candidateId, options = {}) { + const existing = await readRepositoryIdRef(storeRoot, options); + if (existing) { + if (existing !== candidateId) { + throw new Error("workspace lock store repository identity ref conflicts with legacy identity"); + } + return existing; + } + const blobResult = await runGitCommand(["hash-object", "-w", "--stdin"], { + cwd: storeRoot, ...options, stdinText: candidateId + "\n", + }); + const blobFailure = snapshotFailure("git hash-object repository identity", blobResult); + const candidateOid = blobResult.stdout.trim(); + if (blobFailure || !/^[0-9a-f]{40,64}$/u.test(candidateOid)) { + throw new Error("cannot write lock-store repository identity: " + ( + blobFailure || "invalid object id" + )); + } + const updateResult = await runGitCommand([ + "update-ref", "--no-deref", REPOSITORY_ID_REF, candidateOid, "0".repeat(candidateOid.length), + ], { cwd: storeRoot, ...options }); + if (updateResult.exitCode === 0) return candidateId; + // A concurrent initializer may have won the create-only CAS. Read its value + // instead of treating the expected contention as an initialization failure. + const winner = await readRepositoryIdRef(storeRoot, options); + if (winner) { + if (winner !== candidateId) { + throw new Error("workspace lock store repository identity CAS selected a conflicting value"); + } + return winner; + } + const updateFailure = snapshotFailure("git update-ref repository identity", updateResult); + throw new Error("cannot publish lock-store repository identity: " + ( + updateFailure || "repository identity ref was not created" + )); +} + +async function ensureRepositoryId(storeRoot, options = {}) { + const refIdentity = await readRepositoryIdRef(storeRoot, options); + const idPath = path.join(storeRoot, REPOSITORY_ID_FILE); + let legacyIdentity = null; + try { + legacyIdentity = await readLegacyRepositoryId(idPath, options); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + if (refIdentity && legacyIdentity && refIdentity !== legacyIdentity) { + throw new Error("workspace lock store repository identity sources conflict"); + } + if (refIdentity) { + const publishedLegacy = legacyIdentity ?? + await publishLegacyRepositoryId(storeRoot, refIdentity, options); + if (publishedLegacy !== refIdentity) { + throw new Error("workspace lock store legacy identity conflicts with repository identity ref"); + } + return refIdentity; + } + // Publish the file-compatible anchor first so an older bridge running during + // a rolling upgrade can never create a second UUID/lock domain. `wx` is the + // only required filesystem primitive; partial/crashed writes are malformed + // and fail closed rather than allowing a different identity to be published. + const publishedLegacy = legacyIdentity ?? + await publishLegacyRepositoryId(storeRoot, randomUUID(), options); + return await publishRepositoryIdRef(storeRoot, publishedLegacy, options); +} + +async function ensureWorkspaceQuarantineRoot(storeRoot, options = {}) { + const storeMetadata = await interruptibleFilesystemOperation(lstat(storeRoot), options); + if (!storeMetadata.isDirectory() || storeMetadata.isSymbolicLink()) { + throw new Error("workspace lock store must be a real directory before quarantine state is trusted"); + } + const quarantineRoot = path.join(storeRoot, WORKSPACE_QUARANTINE_DIRECTORY); + const fileMode = await repositoryIdMode(storeRoot, options); + const directoryMode = fileMode | ((fileMode & 0o444) >> 2) | + (process.platform !== "win32" && (fileMode & 0o060) === 0o060 ? 0o2000 : 0); + let metadata = null; + try { + metadata = await interruptibleFilesystemOperation(lstat(quarantineRoot), options); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + if (!metadata) { + const creatingCandidate = mkdtemp(path.join(storeRoot, ".cli-agent-bridge-quarantines-")); + let candidateRoot; + try { + candidateRoot = await interruptibleFilesystemOperation(creatingCandidate, options); + } catch (error) { + void creatingCandidate.then((lateRoot) => rm(lateRoot, { recursive: true, force: true })) + .catch(() => {}); + throw error; + } + try { + if (process.platform !== "win32") { + await interruptibleFilesystemOperation(chmod(candidateRoot, directoryMode), options); + } + try { + await interruptibleFilesystemOperation(rename(candidateRoot, quarantineRoot), options); + } catch (error) { + if (!["EEXIST", "ENOTEMPTY", "EPERM"].includes(error.code)) throw error; + } + } finally { + await rm(candidateRoot, { recursive: true, force: true }); + } + metadata = await interruptibleFilesystemOperation(lstat(quarantineRoot), options); + } + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error("workspace quarantine root must be a real directory inside the lock store"); + } + if (process.platform !== "win32" && + (metadata.mode & directoryMode) !== directoryMode) { + throw new Error("workspace quarantine root does not preserve the repository sharing mode"); + } + return quarantineRoot; +} + +async function ensureWorkspaceLockStore(gitCommonDir, options = {}) { + const storeRoot = path.join(gitCommonDir, WORKSPACE_LOCK_STORE_NAME); + let initialized = false; + try { + const head = await interruptibleFilesystemOperation(stat(path.join(storeRoot, "HEAD")), options); + initialized = head.isFile(); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + if (!initialized) { + const sharedResult = await runGitCommand([ + "--git-dir", gitCommonDir, "config", "--get", "core.sharedRepository", + ], { cwd: gitCommonDir, ...options }); + const sharedFailure = sharedResult.exitCode === 1 && !sharedResult.stdout.trim() + ? "" + : snapshotFailure("git config core.sharedRepository", sharedResult); + if (sharedFailure) { + throw new Error("cannot inspect repository sharing mode: " + sharedFailure); + } + const shared = sharedResult.stdout.replace(/\r?\n$/u, ""); + const creatingCandidate = mkdtemp(path.join(gitCommonDir, ".cli-agent-bridge-lock-store-")); + let candidateRoot; + try { + candidateRoot = await interruptibleFilesystemOperation(creatingCandidate, options); + } catch (error) { + void creatingCandidate.then((lateRoot) => rm(lateRoot, { recursive: true, force: true })).catch(() => {}); + throw error; + } + try { + const initArgs = ["init", "--bare", "--quiet", "--template="]; + if (shared) initArgs.push("--shared=" + shared); + initArgs.push(candidateRoot); + const result = await runGitCommand(initArgs, { + cwd: gitCommonDir, ...options, containProcessTree: true, + }); + const failure = snapshotFailure("git init --bare workspace lock store", result); + if (failure) throw new Error("cannot initialize workspace lock store: " + failure); + try { + await interruptibleFilesystemOperation(rename(candidateRoot, storeRoot), options); + } catch (error) { + if (!["EEXIST", "ENOTEMPTY", "EPERM"].includes(error.code)) throw error; + } + } finally { + await rm(candidateRoot, { recursive: true, force: true }); + } + } + const head = await interruptibleFilesystemOperation(stat(path.join(storeRoot, "HEAD")), options); + if (!head.isFile()) throw new Error("workspace lock store HEAD is not a file"); + // On Linux storeRoot may be below /proc//fd/. + // Keep that stable alias instead of resolving it back to a pathname that a + // concurrent repository rename can invalidate while the lease is active. + const repositoryId = await ensureRepositoryId(storeRoot, options); + return { + root: storeRoot, + repositoryId, + quarantineRoot: await ensureWorkspaceQuarantineRoot(storeRoot, options), + }; +} + +function snapshotFailure(label, result) { + if (result.treeTerminated === false) { + return label + " process tree could not be confirmed terminated" + + (result.terminationError ? ": " + result.terminationError : ""); + } + if (result.timedOut) return label + " timed out"; + if (result.stdoutTruncated || result.stderrTruncated) { + return label + " exceeded the " + String(MAX_CAPTURE_CHARS) + " character capture limit"; + } + if (result.errorMessage) return label + " could not start: " + result.errorMessage; + if (result.exitCode !== 0) return label + " failed with exit code " + String(result.exitCode); + return ""; +} + +class OperationCancelledError extends Error {} +class DeadlineExceededError extends Error {} +class InvalidArgumentsError extends Error {} +class BackendConfigurationCleanupError extends Error {} +class GitProcessTreeUnconfirmedError extends Error { + constructor(label, terminationError, quarantine = null) { + super(label + " process tree could not be confirmed terminated: " + terminationError); + this.terminationError = terminationError; + this.quarantine = quarantine; + } +} + +const BACKEND_GIT_ROUTING_VARIABLES = new Set([ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_CONFIG", + "GIT_CEILING_DIRECTORIES", "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS", "GIT_DIR", + "GIT_DISCOVERY_ACROSS_FILESYSTEM", "GIT_GRAFT_FILE", + "GIT_IMPLICIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_NAMESPACE", "GIT_NO_REPLACE_OBJECTS", + "GIT_OBJECT_DIRECTORY", "GIT_PREFIX", "GIT_QUARANTINE_PATH", "GIT_REFERENCE_BACKEND", + "GIT_REPLACE_REF_BASE", + "GIT_SHALLOW_FILE", "GIT_WORK_TREE", +]); + +function gitCommandIndex(argv) { + const valueOptions = new Set([ + "-C", "-c", "--config-env", "--exec-path", "--git-dir", "--namespace", + "--super-prefix", "--work-tree", + ]); + for (let index = 1; index < argv.length; index += 1) { + const value = String(argv[index] ?? ""); + if (value === "--") return index + 1 < argv.length ? index + 1 : -1; + if (!value.startsWith("-")) return index; + if (!value.includes("=") && valueOptions.has(value)) index += 1; + } + return -1; +} + +export function backendGitProvenanceEnvironment(tracePath, baseEnvironment = process.env) { + if (typeof tracePath !== "string" || !path.isAbsolute(tracePath)) { + throw new Error("Git fetch provenance trace path is unavailable"); + } + const env = { ...baseEnvironment }; + for (const name of Object.keys(env)) { + const canonical = process.platform === "win32" ? name.toUpperCase() : name; + if (BACKEND_GIT_ROUTING_VARIABLES.has(canonical) || + /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/u.test(canonical) || + canonical === "GIT_REFLOG_ACTION" || canonical.startsWith("GIT_TRACE")) { + delete env[name]; + } + } + // Trace2 identifies completed fetch/pull attempts in the canonical target + // repository. A nonzero fetch may already have updated some refs, and Git + // exposes no complete cross-version per-fetch tip log, so any such attempt + // makes attribution unavailable instead of trusting the last FETCH_HEAD. + env.GIT_TRACE2_EVENT = tracePath; + return env; +} + +async function createBackendGitProvenance(baseEnvironment = process.env, options = {}) { + let root = ""; + let tracePath = ""; + let handle = null; + let pendingOperation = Promise.resolve(); + const step = async (start, remember = () => {}) => { + checkTraceInterruption(options); + const operation = Promise.resolve().then(start).then((value) => { + remember(value); + return value; + }); + pendingOperation = operation.catch(() => {}); + return await interruptibleFilesystemOperation(operation, options); + }; + const cleanup = async () => { + await pendingOperation; + await handle?.close().catch(() => {}); + if (root) await rm(root, { recursive: true, force: true }).catch(() => {}); + }; + try { + root = await step( + () => mkdtemp(path.join(os.tmpdir(), "minimax-cli-agent-fetch-")), + (value) => { root = value; tracePath = path.join(value, "git.trace"); }, + ); + if (process.env.NODE_ENV === "test") { + const releaseFile = process.env.CLI_AGENT_BRIDGE_TEST_TRACE_PENDING_STEP_RELEASE_FILE; + if (typeof releaseFile === "string" && path.isAbsolute(releaseFile)) { + await step(async () => { + const startedFile = process.env.CLI_AGENT_BRIDGE_TEST_TRACE_CREATION_STARTED_FILE; + if (typeof startedFile === "string" && path.isAbsolute(startedFile)) { + await writeFile(startedFile, root + "\n", { flag: "a" }); + } + while (true) { + try { + await stat(releaseFile); + break; + } catch (error) { + if (error.code !== "ENOENT") throw error; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + }); + } + } + await step(() => chmod(root, 0o700)); + const rootIdentity = await step(() => lstat(root, { bigint: true })); + if (!rootIdentity.isDirectory() || rootIdentity.isSymbolicLink() || + !sameTraceIdentity(rootIdentity, rootIdentity)) { + throw new Error("Git fetch provenance root has no stable directory identity"); + } + handle = await step( + () => open(tracePath, "wx+", 0o600), + (value) => { handle = value; }, + ); + await step(() => handle.chmod(0o600)); + const identity = await step(() => handle.stat({ bigint: true })); + if (!identity.isFile() || !sameTraceIdentity(identity, identity)) { + throw new Error("Git fetch provenance trace has no stable regular-file identity"); + } + if (process.env.NODE_ENV === "test") { + const delayMs = Number(process.env.CLI_AGENT_BRIDGE_TEST_TRACE_CREATION_DELAY_MS ?? 0); + if (Number.isFinite(delayMs) && delayMs > 0) { + const startedFile = process.env.CLI_AGENT_BRIDGE_TEST_TRACE_CREATION_STARTED_FILE; + if (typeof startedFile === "string" && path.isAbsolute(startedFile)) { + await writeFile(startedFile, root + "\n", { flag: "a" }); + } + await interruptibleFilesystemOperation( + new Promise((resolve) => setTimeout(resolve, delayMs)), options, + ); + } + } + return { + root, rootIdentity, tracePath, handle, identity, + env: backendGitProvenanceEnvironment(tracePath, baseEnvironment), + }; + } catch (error) { + if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError) { + // A raced filesystem call may finish after the request unwinds. Its + // remembered root/handle is cleaned asynchronously once that call settles. + trackBackgroundFinalizer(cleanup()); + } else { + await cleanup(); + } + throw error; + } +} + +function sameTraceIdentity(left, right) { + return left.dev !== 0n && left.ino !== 0n && + left.dev === right.dev && left.ino === right.ino; +} + +function sameTraceSnapshot(left, right) { + return sameTraceIdentity(left, right) && left.size === right.size && + left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs; +} + +async function readBoundedTrace(handle, options = {}) { + return await readBoundedFileHandle(handle, MAX_CAPTURE_CHARS, options); +} + +async function readBoundedFileHandle(handle, limit, options = {}) { + const captured = Buffer.allocUnsafe(limit + 1); + let offset = 0; + while (offset < captured.length) { + checkTraceInterruption(options); + const length = Math.min(64 * 1024, captured.length - offset); + const { bytesRead } = await interruptibleFilesystemOperation( + handle.read(captured, offset, length, offset), options, + ); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset > limit) { + throw new Error("bounded file exceeded the capture limit"); + } + return new TextDecoder("utf-8", { fatal: true }).decode(captured.subarray(0, offset)); +} + +export async function readBoundedRegularFile(filePath, limit, options = {}) { + const flags = fsConstants.O_RDONLY | + (fsConstants.O_NONBLOCK ?? 0) | + (process.platform === "win32" ? 0 : (fsConstants.O_NOFOLLOW ?? 0)); + const opening = open(filePath, flags); + let handle; + try { + handle = await interruptibleFilesystemOperation(opening, options); + } catch (error) { + void opening.then((lateHandle) => lateHandle.close()).catch(() => {}); + throw error; + } + try { + const [before, pathBefore] = await Promise.all([ + interruptibleFilesystemOperation(handle.stat({ bigint: true }), options), + interruptibleFilesystemOperation(lstat(filePath, { bigint: true }), options), + ]); + if (!before.isFile() || !pathBefore.isFile() || + !sameTraceIdentity(before, pathBefore)) { + throw new Error("path is not the opened regular file"); + } + if (before.size > BigInt(limit)) throw new Error("file exceeded the capture limit"); + const value = await readBoundedFileHandle(handle, limit, options); + const [after, pathAfter] = await Promise.all([ + interruptibleFilesystemOperation(handle.stat({ bigint: true }), options), + interruptibleFilesystemOperation(lstat(filePath, { bigint: true }), options), + ]); + if (!pathAfter.isFile() || !sameTraceSnapshot(before, after) || + !sameTraceIdentity(after, pathAfter)) { + throw new Error("regular file changed while it was being read"); + } + return value; + } finally { + await handle.close().catch(() => {}); + } +} + +function checkTraceInterruption({ cancel = null, deadline = null } = {}) { + if (cancel?.cancelled) throw new OperationCancelledError("operation cancelled by client"); + if (deadline !== null && Date.now() >= deadline) { + throw new DeadlineExceededError("delegation deadline exceeded"); + } +} + +export async function readBackendGitProvenance(provenance, worktreeRoot, options = {}) { + if (!provenance?.handle || !provenance?.identity) { + throw new Error("Git fetch provenance trace handle is unavailable"); + } + checkTraceInterruption(options); + const [before, pathBefore] = await Promise.all([ + interruptibleFilesystemOperation(provenance.handle.stat({ bigint: true }), options), + interruptibleFilesystemOperation(lstat(provenance.tracePath, { bigint: true }), options), + ]); + if (!before.isFile() || !pathBefore.isFile() || + !sameTraceIdentity(before, provenance.identity) || !sameTraceIdentity(pathBefore, before)) { + throw new Error("Git fetch provenance trace path no longer identifies the original regular file"); + } + if (before.size > BigInt(MAX_CAPTURE_CHARS)) { + throw new Error("Git fetch provenance trace exceeded the capture limit"); + } + const trace = await readBoundedTrace(provenance.handle, options); + checkTraceInterruption(options); + const [after, pathAfter] = await Promise.all([ + interruptibleFilesystemOperation(provenance.handle.stat({ bigint: true }), options), + interruptibleFilesystemOperation(lstat(provenance.tracePath, { bigint: true }), options), + ]); + if (!pathAfter.isFile() || !sameTraceSnapshot(before, after) || + !sameTraceIdentity(pathAfter, after)) { + throw new Error("Git fetch provenance trace changed while it was being read"); + } + const sessions = new Map(); + const normalizeWorktree = (value) => { + return path.resolve(String(value ?? "")); + }; + const targetWorktree = normalizeWorktree(worktreeRoot); + let offset = 0; + let nextYield = TRACE_PARSE_YIELD_CHARS; + checkTraceInterruption(options); + while (offset <= trace.length) { + const newline = trace.indexOf("\n", offset); + const end = newline === -1 ? trace.length : newline; + if (end - offset > MAX_TRACE_EVENT_CHARS) { + throw new Error("Git fetch provenance trace contained an oversized Trace2 event"); + } + let line = trace.slice(offset, end); + if (line.endsWith("\r")) line = line.slice(0, -1); + if (line.startsWith("{")) { + let event; + try { + event = JSON.parse(line); + } catch { + throw new Error("Git fetch provenance trace contained malformed Trace2 JSON"); + } + if (event?.event === "start") { + const commandIndex = Array.isArray(event.argv) ? gitCommandIndex(event.argv) : -1; + const command = commandIndex >= 0 ? event.argv[commandIndex] : ""; + if (command === "fetch" || command === "pull") { + sessions.set(event.sid, { command, worktree: null, exitCode: null, exited: false }); + } + } else if (event?.event === "def_repo") { + const session = sessions.get(event.sid); + if (session) session.worktree = normalizeWorktree(event.worktree); + } else if (event?.event === "exit") { + const session = sessions.get(event.sid); + if (session) { + session.exited = true; + session.exitCode = event.code; + } + } + } + if (newline === -1) break; + offset = end + 1; + if (offset >= nextYield) { + checkTraceInterruption(options); + await new Promise((resolve) => setImmediate(resolve)); + checkTraceInterruption(options); + nextYield = offset + TRACE_PARSE_YIELD_CHARS; + } + } + let uncertain = false; + let sawFetch = false; + for (const session of sessions.values()) { + if (session.worktree === null || !session.exited) { + uncertain = true; + continue; + } + // A non-zero fetch can still update a subset of its destinations before a + // later ref is rejected. Completion in the target repository is therefore + // enough to make exact commit attribution unavailable; exit status cannot + // prove that the repository was left untouched. + if (session.worktree === targetWorktree) { + sawFetch = true; + uncertain = true; + } + } + return { uncertain, sawFetch }; +} + +async function cleanupBackendGitProvenance(provenance) { + // Only operate through the retained original handle and exact path names. + // The backend knows this directory and may replace entries inside it, so a + // recursive removal could be redirected or made unbounded after the worker. + const options = { deadline: Date.now() + TRACE_CLEANUP_TIMEOUT_MS }; + await interruptibleFilesystemOperation( + provenance?.handle?.truncate(0), options, + ).catch(() => {}); + await interruptibleFilesystemOperation( + provenance?.handle?.close(), options, + ).catch(() => {}); + try { + checkTraceInterruption(options); + const root = await interruptibleFilesystemOperation( + lstat(provenance.root, { bigint: true }), options, + ); + if (!root.isDirectory() || root.isSymbolicLink() || + !sameTraceIdentity(root, provenance.rootIdentity)) return; + await interruptibleFilesystemOperation(unlink(provenance.tracePath), options).catch(() => {}); + await interruptibleFilesystemOperation(rmdir(provenance.root), options).catch(() => {}); + } catch { + // A replaced/missing root is untrusted. Leave it untouched; the retained + // original trace handle has already been truncated and closed. + } +} + +export async function runGitCommand(args, { + cwd, + cancel = null, + deadline = null, + stdinText, + binaryStdout = false, + timeoutMs = GIT_TIMEOUT_MS, + containProcessTree = false, + commandRunner = runCommand, +} = {}) { + if (cancel?.cancelled) throw new OperationCancelledError("operation cancelled by client"); + const resolutionDeadline = deadline ?? Date.now() + timeoutMs; + const executable = await resolveTrustedGitExecutable({ cancel, deadline: resolutionDeadline }); + const git = await safeGitInvocation(args, process.env, executable); + if (cancel?.cancelled) throw new OperationCancelledError("operation cancelled by client"); + const remaining = resolutionDeadline - Date.now(); + if (remaining <= 0) throw new DeadlineExceededError("delegation deadline exceeded"); + let controller = null; + const result = await commandRunner(git.command, git.args, { + cwd, + env: git.env, + stdinText, + binaryStdout, + manageProcessTree: containProcessTree, + markerObservationGraceMs: 0, + timeoutMs: Math.max(1, Math.min(timeoutMs, remaining)), + killGraceMs: 1_000, + shouldCancel: () => Boolean(cancel?.cancelled), + onChild: (current) => { + controller = current; + if (cancel) cancel.controller = current; + }, + }); + if (cancel?.controller === controller) cancel.controller = null; + // A contained Git command can be cancelled or hit the overall deadline at + // the same time that descendant cleanup becomes uncertain. Preserve that + // result so the snapshot caller can quarantine the retained lease before + // reporting the interruption; throwing here would release the lease. + if (!(containProcessTree && result.treeTerminated === false)) { + if (cancel?.cancelled) throw new OperationCancelledError("operation cancelled by client"); + if (deadline !== null && result.timedOut && Date.now() >= deadline) { + throw new DeadlineExceededError("delegation deadline exceeded"); + } + } + return result; +} + +// Every foreign exact lease ref is conservatively active until its owner CAS +// removes it. State and wall-clock timestamps cannot prove that a worker (or a +// worker on another host sharing the repository) is outside our snapshot. + +export async function readRepositoryLockActivity(lockStoreRoot, ownLockRef, options = {}) { + if (!lockStoreRoot) return { activeRefs: 0, historyRefs: new Map() }; + const readRefs = async (label) => { + const storedRefs = await runGitCommand([ + "for-each-ref", "--format=%(refname)%09%(objectname)", WORKSPACE_LOCK_REF_PREFIX, + ], { cwd: lockStoreRoot, ...options }); + const failure = snapshotFailure(label, storedRefs); + if (failure) throw new Error("git snapshot unreliable: " + failure); + return String(storedRefs.stdout ?? "").split(/\r?\n/u); + }; + + // These are intentionally two ordered Git snapshots, not two views parsed + // from one for-each-ref result. A lease exists before its marker is + // published: if the history scan already sees a new marker, the later live + // scan must see its lease unless that run completed before target ref capture. + const historyRefs = new Map(); + for (const line of await readRefs("git for-each-ref workspace activity history")) { + if (!line) continue; + const separator = line.indexOf("\t"); + if (separator <= 0) continue; + const ref = line.slice(0, separator); + const oid = line.slice(separator + 1); + if (ref === ownLockRef || ref === ownLockRef + ".history") continue; + if (/^refs\/cli-agent-bridge\/workspace-locks\/[0-9a-f]{64}\.history$/u.test(ref)) { + historyRefs.set(ref, oid); + } + } + let activeRefs = 0; + for (const line of await readRefs("git for-each-ref workspace active leases")) { + if (!line) continue; + const separator = line.indexOf("\t"); + if (separator <= 0) continue; + const ref = line.slice(0, separator); + if (ref === ownLockRef) continue; + if (/^refs\/cli-agent-bridge\/workspace-locks\/[0-9a-f]{64}$/u.test(ref)) { + // Ref existence is the conservative signal. Malformed, idle, pending, + // or quarantined owners cannot be dismissed using a foreign host clock. + activeRefs += 1; + } + } + return { activeRefs, historyRefs }; +} + +async function gitSnapshot(worktreeRoot, options = {}) { + const ownLockRef = typeof options.ownLockRef === "string" ? options.ownLockRef : null; + const lockStoreRoot = typeof options.lockStoreRoot === "string" ? options.lockStoreRoot : null; + const historyBaseline = options.concurrencyHistoryBaseline instanceof Map + ? options.concurrencyHistoryBaseline + : null; + // The before vector must precede every target-repository observation. If it + // were sampled at the end, a run completing between ref capture and this + // marker could be swallowed into the baseline and misattributed later. + let lockActivity = historyBaseline === null + ? await readRepositoryLockActivity(lockStoreRoot, ownLockRef, options) + : null; + const jobs = [ + ["git status --short", "status", ["status", "--short", "--untracked-files=all", "--ignore-submodules=none"], false, false, true], + ["git diff --stat", "diffStat", ["diff", "--ignore-submodules=none", "--stat"], false, false, true], + ["git diff --name-only -z", "diffNames", ["diff", "--ignore-submodules=none", "--name-only", "-z"], false, true, true], + ["git diff --cached --stat", "cachedDiffStat", ["diff", "--cached", "--ignore-submodules=none", "--stat"]], + ["git diff --cached --name-only -z", "cachedDiffNames", ["diff", "--cached", "--ignore-submodules=none", "--name-only", "-z"], false, true], + ["git ls-files --others --exclude-standard -z", "untracked", ["ls-files", "--others", "--exclude-standard", "-z"], false, true], + ["git rev-parse --verify --quiet HEAD", "head", ["rev-parse", "--verify", "--quiet", "HEAD"], true], + ["git symbolic-ref --quiet HEAD", "headRef", ["symbolic-ref", "--quiet", "HEAD"], true], + ["git for-each-ref", "refs", ["for-each-ref", "--format=%(refname)%09%(objectname)", "refs"]], + ["git rev-parse --git-path FETCH_HEAD", "fetchHeadPath", ["rev-parse", "--git-path", "FETCH_HEAD"]], + ]; + // Run serially: status/diff may both refresh the index, so concurrent Git + // processes can race for .git/index.lock on the same repository. + const out = {}; + for (const job of jobs) { + const result = await runGitCommand(job[2], { + cwd: worktreeRoot, ...options, binaryStdout: job[4] === true, + // Worktree status/diff can execute arbitrary clean filters despite + // --no-ext-diff/--no-textconv. Keep those commands contained; commands + // that only read refs/index metadata stay on the lightweight path. + containProcessTree: options.containProcessTree === true || job[5] === true, + }); + const contained = options.containProcessTree === true || job[5] === true; + if (contained && result.treeTerminated === false) { + const terminationError = result.terminationError || + "repository helper descendants may still be running"; + const quarantine = typeof options.onUnconfirmedProcessTree === "function" + ? await options.onUnconfirmedProcessTree({ label: job[0], terminationError }) + : null; + throw new GitProcessTreeUnconfirmedError(job[0], terminationError, quarantine); + } + if (job[3] === true && result.exitCode === 1 && !result.timedOut) { + out[job[1]] = ""; + continue; + } + const failure = snapshotFailure(job[0], result); + // Fail closed immediately: continuing with later commands only delays + // lease release when a repository was renamed or removed mid-delegation. + if (failure) throw new Error("git snapshot unreliable: " + failure); + out[job[1]] = result.stdout; + } + const seen = new Set(); + const nulNames = (value) => { + if (!Buffer.isBuffer(value)) { + return String(value ?? "").split("\0").filter((name) => name.length > 0); + } + const names = []; + const decoder = new TextDecoder("utf-8", { fatal: true }); + let start = 0; + for (let index = 0; index <= value.length; index += 1) { + if (index < value.length && value[index] !== 0) continue; + if (index > start) { + const raw = value.subarray(start, index); + try { + names.push(decoder.decode(raw)); + } catch { + // JSON strings cannot contain invalid UTF-8 bytes. A leading NUL can + // never be a legal Git path, so this reserved representation is both + // unambiguous and lossless for callers that need the original bytes. + names.push("\0git-path-bytes:" + raw.toString("hex")); + } + } + start = index + 1; + } + return names; + }; + const changedFiles = [ + ...nulNames(out.diffNames), + ...nulNames(out.cachedDiffNames), + ...nulNames(out.untracked), + ].filter((f) => (seen.has(f) ? false : (seen.add(f), true))); + const diffStat = [ + ["", String(out.diffStat ?? "").trim()], + ["staged: ", String(out.cachedDiffStat ?? "").trim()], + ] + .filter(([, value]) => Boolean(value)) + .map(([prefix, value]) => value.split(/\r?\n/u).map((line) => prefix + line).join("\n")) + .join("\n"); + const refs = {}; + for (const line of String(out.refs ?? "").split(/\r?\n/u)) { + if (!line) continue; + const separator = line.indexOf("\t"); + if (separator <= 0) continue; + const ref = line.slice(0, separator); + refs[ref] = line.slice(separator + 1); + } + const fetchHeads = []; + const fetchHeadPath = path.resolve( + worktreeRoot, String(out.fetchHeadPath ?? "").replace(/\r?\n$/u, ""), + ); + try { + const rawFetchHead = await readBoundedRegularFile(fetchHeadPath, MAX_CAPTURE_CHARS, options); + for (const line of rawFetchHead.split(/\r?\n/u)) { + if (!line) continue; + const oid = line.split("\t", 1)[0]; + if (!/^[0-9a-f]{40,64}$/u.test(oid)) { + throw new Error("malformed FETCH_HEAD record"); + } + if (!fetchHeads.includes(oid)) fetchHeads.push(oid); + } + } catch (error) { + if (error.code !== "ENOENT") { + if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError) throw error; + throw new Error("git snapshot unreliable: cannot read FETCH_HEAD: " + error.message); + } + } + lockActivity ??= await readRepositoryLockActivity(lockStoreRoot, ownLockRef, options); + // Linked worktrees serialize per worktree but share repository refs, so a + // commit from a parallel delegation can land between our two snapshots. + // Detection combines two clock-independent signals: leases that are active + // at either snapshot, and a changed persistent history-ref OID between the + // snapshots. Wall clocks from two hosts sharing a repository are not + // comparable and must never decide whether attribution is exact. + let concurrentDelegations = lockActivity.activeRefs; + if (historyBaseline) { + const historyNames = new Set([...historyBaseline.keys(), ...lockActivity.historyRefs.keys()]); + for (const ref of historyNames) { + if (historyBaseline.get(ref) !== lockActivity.historyRefs.get(ref)) { + concurrentDelegations += 1; + } + } + } + const snapshot = { + // The leading space in porcelain's first XY column is significant (for + // example, " M" means unstaged). Remove only Git's final line terminator. + statusShort: String(out.status ?? "").replace(/\r?\n$/u, ""), + diffStat, + changedFiles, + head: String(out.head ?? "").trim(), + headRef: String(out.headRef ?? "").replace(/\r?\n$/u, ""), + refs, + fetchHeads, + concurrentDelegations, + }; + Object.defineProperty(snapshot, LOCK_HISTORY_REFS, { value: lockActivity.historyRefs }); + return snapshot; +} + +// Peel an object id to a commit id. Returns null for blob/tree objects (legal +// ref targets) and for tags that do not dereference to a commit. +async function peelCommitish(worktreeRoot, oid, cache = new Map(), options = {}) { + if (!oid || !/^[0-9a-f]{40,64}$/u.test(oid)) return null; + if (cache.has(oid)) return cache.get(oid); + const typeResult = await runGitCommand(["cat-file", "-t", oid], { + cwd: worktreeRoot, ...options, + }); + let commit = null; + if (typeResult.exitCode === 0) { + const type = typeResult.stdout.trim(); + if (type === "commit") { + commit = oid; + } else if (type === "tag") { + const peeled = await runGitCommand( + ["rev-parse", "--verify", "--quiet", oid + "^{commit}"], + { cwd: worktreeRoot, ...options }, + ); + if (peeled.exitCode === 0 && peeled.stdout.trim()) commit = peeled.stdout.trim(); + } + } + cache.set(oid, commit); + return commit; +} + +export async function populateCommitishCache(worktreeRoot, oids, cache, options = {}) { + const pending = [...new Set(oids)].filter((oid) => + typeof oid === "string" && /^[0-9a-f]{40,64}$/u.test(oid) && !cache.has(oid)); + // Keep each response comfortably below the bounded capture limit while + // reducing thousands of per-ref cat-file processes to a few batch queries. + const chunkSize = 4_000; + for (let offset = 0; offset < pending.length; offset += chunkSize) { + const chunk = pending.slice(offset, offset + chunkSize); + const result = await runGitCommand([ + "cat-file", "--batch-check=%(objectname) %(objecttype)", + ], { + cwd: worktreeRoot, + ...options, + stdinText: chunk.map((oid) => oid + "^{commit}").join("\n") + "\n", + }); + const failure = snapshotFailure("git cat-file --batch-check", result); + if (failure) throw new Error("cannot classify repository tips: " + failure); + const lines = String(result.stdout ?? "").replace(/\r?\n$/u, "").split(/\r?\n/u); + if (lines.length !== chunk.length) { + throw new Error("cannot classify repository tips: incomplete git cat-file response"); + } + for (let index = 0; index < chunk.length; index += 1) { + const match = /^([0-9a-f]{40,64}) commit$/u.exec(lines[index]); + if (!match && !lines[index].endsWith(" missing")) { + throw new Error("cannot classify repository tips: malformed git cat-file response"); + } + cache.set(chunk[index], match ? match[1] : null); + } + } +} + +export async function closestExistingBase(worktreeRoot, target, baselineCommits, options = {}) { + if (baselineCommits.length === 0) return null; + const { preferredBase = null, ...gitOptions } = options; + // One boundary walk finds the pre-run commits immediately adjacent to the + // target's new history. Supplying exclusions on stdin avoids command-line + // limits and, unlike one merge-base/rev-list process per ref, keeps Git + // process count constant even for repositories with thousands of tips. + const walk = await runGitCommand([ + "rev-list", "--topo-order", "--boundary", "--parents", target, "--stdin", + ], { + cwd: worktreeRoot, + ...gitOptions, + stdinText: baselineCommits.map((commit) => "^" + commit).join("\n") + "\n", + }); + const failure = snapshotFailure("git rev-list --boundary " + target, walk); + if (failure) throw new Error("cannot select committed-delta baseline: " + failure); + const parents = new Map(); + const boundaries = []; + for (const line of String(walk.stdout ?? "").split(/\r?\n/u)) { + if (!line) continue; + const fields = line.trim().split(/\s+/u); + const isBoundary = fields[0].startsWith("-"); + const commit = isBoundary ? fields[0].slice(1) : fields[0]; + if (!/^[0-9a-f]{40,64}$/u.test(commit)) continue; + parents.set(commit, fields.slice(1).filter((oid) => /^[0-9a-f]{40,64}$/u.test(oid))); + if (isBoundary) boundaries.push(commit); + } + if (boundaries.length === 0) return null; + + // Boundary output is topological, not a distance ordering. Find the nearest + // excluded commits from the target so a more distant old ref tip cannot pull + // pre-existing intermediate history into the stat. When several boundaries + // are equally close (for example both parents of a merge), prefer this ref's + // own previous tip to report what the merge introduced into that ref. + const boundarySet = new Set(boundaries); + const distances = new Map([[target, 0]]); + const queue = [target]; + for (let index = 0; index < queue.length; index += 1) { + const commit = queue[index]; + const distance = distances.get(commit); + for (const parent of parents.get(commit) ?? []) { + if (distances.has(parent)) continue; + distances.set(parent, distance + 1); + if (!boundarySet.has(parent)) queue.push(parent); + } + } + let nearestDistance = Number.POSITIVE_INFINITY; + let nearest = []; + for (const boundary of boundaries) { + const distance = distances.get(boundary) ?? Number.POSITIVE_INFINITY; + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = [boundary]; + } else if (distance === nearestDistance) { + nearest.push(boundary); + } + } + if (nearest.includes(preferredBase)) return preferredBase; + if (nearest.length > 0 && Number.isFinite(nearestDistance)) return nearest[0]; + // Disjoint histories have no excluded boundary; callers use the empty tree + // so the stat still represents the newly reachable target history. + return boundaries[0]; +} + +export async function committedDelta(worktreeRoot, before, after, options = {}) { + const refNames = new Set([...Object.keys(before.refs ?? {}), ...Object.keys(after.refs ?? {})]); + const refsChanged = [...refNames].sort().flatMap((ref) => { + const beforeOid = before.refs?.[ref] ?? ""; + const afterOid = after.refs?.[ref] ?? ""; + return beforeOid === afterOid ? [] : [{ ref, before: beforeOid, after: afterOid }]; + }); + const provenance = after?.[FETCH_PROVENANCE_TIPS] ?? null; + if (provenance?.uncertain) { + return { + attributionUnavailable: true, + attributionError: "Git fetch or pull ran in the workspace; per-fetch external tips could not be proven completely", + refsChanged, + newCommitCount: null, + log: "", + diffStat: null, + }; + } + if (before.head === after.head && before.headRef === after.headRef && refsChanged.length === 0) return null; + + let emptyTreeId = ""; + async function emptyTree() { + if (emptyTreeId) return emptyTreeId; + const emptyTree = await runGitCommand(["mktree"], { + cwd: worktreeRoot, + ...options, + stdinText: "", + containProcessTree: true, + }); + const failure = snapshotFailure("git mktree", emptyTree); + if (failure || !emptyTree.stdout.trim()) { + throw new Error("cannot compute committed delta from unborn HEAD: " + (failure || "empty tree id missing")); + } + emptyTreeId = emptyTree.stdout.trim(); + return emptyTreeId; + } + + const cache = new Map(); + await populateCommitishCache(worktreeRoot, [ + before.head, + ...Object.values(before.refs ?? {}), + ...(before.fetchHeads ?? []), + after.head, + ...Object.values(after.refs ?? {}), + ...refsChanged.flatMap((change) => [change.before, change.after]), + ], cache, options); + // Baseline: every commit that already existed before the worker ran. New + // commits are attributed to the worker only when they are reachable from the + // after-state but from none of these, so merely checking out an existing + // divergent branch is never reported as "commits made by the worker". + const baselineCommits = []; + async function addBaseline(oid) { + const commit = await peelCommitish(worktreeRoot, oid, cache, options); + if (commit && !baselineCommits.includes(commit)) baselineCommits.push(commit); + } + await addBaseline(before.head); + for (const oid of new Set([ + ...Object.values(before.refs ?? {}), + ...(before.fetchHeads ?? []), + ...refsChanged.map((change) => change.before), + ])) { + await addBaseline(oid); + } + // A worker committing on the checked-out branch moves HEAD and its branch ref + // across the same object pair; deduplicate by that pair so the log, diff, and + // commit count are emitted once with both labels. + const targets = []; + const targetIndex = new Map(); + function addTarget(label, beforeOid, afterOid) { + const key = (beforeOid || "") + "\0" + (afterOid || ""); + const existing = targetIndex.get(key); + if (existing !== undefined) { + targets[existing].labels.push(label); + return; + } + targetIndex.set(key, targets.length); + targets.push({ labels: [label], beforeOid, afterOid }); + } + addTarget("HEAD", before.head, after.head); + for (const change of refsChanged) { + addTarget(change.ref, change.before, change.after); + } + + const movementLogs = []; + if ((before.headRef ?? "") !== (after.headRef ?? "")) { + movementLogs.push( + "HEAD symbolic target " + (before.headRef || "(detached)") + + " -> " + (after.headRef || "(detached)"), + ); + } + const statNotes = []; + const statRanges = new Map(); + const attributedCommits = new Map(); + for (const { labels, beforeOid, afterOid } of targets) { + if (!afterOid || beforeOid === afterOid) continue; + const label = labels.join(", "); + const target = await peelCommitish(worktreeRoot, afterOid, cache, options); + if (!target) { + // Legal non-commit ref (for example a tag pointing at a blob): report the + // movement, never build a commit range from it. + movementLogs.push(label + " -> " + afterOid + " (non-commit object; no commit log)"); + statNotes.push(label + ": (non-commit ref target)"); + continue; + } + // Everything reachable from the pre-delegation state is excluded, so only + // commits the worker actually created remain attributed to it. + const exclusions = baselineCommits; + const revList = await runGitCommand( + exclusions.length > 0 + ? ["log", "--format=%H%x09%s", target, "--stdin"] + : ["log", "--format=%H%x09%s", target], + { + cwd: worktreeRoot, + ...options, + stdinText: exclusions.length > 0 + ? exclusions.map((commit) => "^" + commit).join("\n") + "\n" + : undefined, + }, + ); + const revListFailure = snapshotFailure("git log " + target, revList); + if (revListFailure) throw new Error("committed delta unreliable: " + revListFailure); + const newCommits = String(revList.stdout ?? "").trim(); + if (!newCommits) { + const note = labels.includes("HEAD") && beforeOid + ? "HEAD moved from " + beforeOid.slice(0, 12) + " to " + afterOid.slice(0, 12) + + " without creating commits (branch checkout or reset); the target history predates the delegation" + : label + " now points to pre-existing history; no new commits"; + movementLogs.push(label + ": " + note); + statNotes.push(label + ": (no new commits)"); + continue; + } + for (const line of newCommits.split("\n")) { + const separator = line.indexOf("\t"); + const oid = separator === -1 ? line : line.slice(0, separator); + const subject = separator === -1 ? "" : line.slice(separator + 1); + const existing = attributedCommits.get(oid); + if (existing) { + for (const item of labels) existing.labels.add(item); + } else { + attributedCommits.set(oid, { oid, subject, labels: new Set(labels) }); + } + } + // Prefer an existing ref's own old tip when it is one of the target's + // adjacent pre-run boundaries. A merge can have several such parents, and + // choosing whichever rev-list prints first can omit the merged side. If a + // different pre-run tip lies between the old ref and target, use that + // closer boundary so already-existing intermediate history stays out. + let base; + const previousTarget = beforeOid + ? await peelCommitish(worktreeRoot, beforeOid, cache, options) + : null; + if (baselineCommits.length > 0) { + base = await closestExistingBase(worktreeRoot, target, baselineCommits, { + ...options, preferredBase: previousTarget, + }) ?? await emptyTree(); + } else if (previousTarget) { + base = previousTarget; + } else { + base = before.head || await emptyTree(); + } + const range = base + ".." + target; + const diff = await runGitCommand(["diff", "--stat", range], { + cwd: worktreeRoot, ...options, + }); + const diffFailure = snapshotFailure("git diff --stat " + range, diff); + if (diffFailure) throw new Error("committed delta unreliable: " + diffFailure); + const existingRange = statRanges.get(range); + if (existingRange) { + for (const item of labels) existingRange.labels.add(item); + } else { + statRanges.set(range, { + labels: new Set(labels), + text: String(diff.stdout ?? "").trim() || "(empty)", + }); + } + } + const commitLogs = [...attributedCommits.values()].map((commit) => + [...commit.labels].join(", ") + ": " + commit.oid.slice(0, 12) + + (commit.subject ? " " + commit.subject : ""), + ); + const logs = [...movementLogs]; + if (commitLogs.length > 0) { + logs.push("worker-created commits (deduplicated across moved refs)\n" + commitLogs.join("\n")); + } + const stats = [ + ...statNotes, + ...[...statRanges.entries()].map(([range, entry]) => + [...entry.labels].join(", ") + " [" + range + "]\n" + entry.text, + ), + ]; + return { + range: logs.length > 0 ? "attribution: new commits only (pre-existing history excluded)" : "", + refsChanged, + newCommitCount: attributedCommits.size, + log: logs.join("\n\n") || "(no ref or HEAD movements)", + diffStat: stats.join("\n\n") || "(empty)", + }; +} + +export function backendEntryFromProbe(name, spec, check) { + const available = check.exitCode === 0 && check.treeTerminated === true && check.timedOut !== true; + const probeStderr = tail(String(check.stderr ?? ""), 500).trim(); + let probeError = ""; + if (check.treeTerminated !== true) { + probeError = check.terminationError || + "backend version probe process tree could not be confirmed terminated"; + } else if (check.timedOut) { + probeError = "backend version probe timed out after " + VERSION_CHECK_TIMEOUT_MS + " ms" + + (probeStderr ? ": " + probeStderr : ""); + } else if (typeof check.exitCode === "number" && check.exitCode !== 0) { + probeError = "backend version probe exited with code " + check.exitCode + + (probeStderr ? ": " + probeStderr : ""); + } else if (check.errorMessage) { + probeError = check.errorMessage; + } else if (probeStderr) { + probeError = "backend version probe failed: " + probeStderr; + } else { + probeError = "command not found or not executable"; + } + return { + name, + label: typeof spec.label === "string" ? spec.label : name, + command: spec.command, + available, + experimental: Boolean(spec.experimental), + version: available ? tail(check.stdout, 200).trim() : null, + error: available ? "" : probeError, + resumeSupported: Array.isArray(spec.resumeArgs), + notes: typeof spec.notes === "string" ? spec.notes : "", + }; +} + +async function listBackends(cancel = null) { + if (cancel?.cancelled) throw new OperationCancelledError("list_backends cancelled by client"); + if (!supportsReliableProcessContainment()) { + return [{ + name: "unsupported-platform", + label: "Unsupported platform", + command: "", + available: false, + experimental: false, + version: null, + error: unsupportedPlatformMessage("list_backends"), + resumeSupported: false, + notes: "No backend configuration or executable was inspected.", + }]; + } + let backends; + try { + backends = await loadBackends({ cancel }); + } catch (error) { + throw error; + } + const entries = []; + for (const [name, spec] of Object.entries(backends)) { + // A hung `--version` probe must not pin the request: the client can cancel + // the discovery call, terminating the current probe and skipping the rest. + if (cancel?.cancelled) throw new OperationCancelledError("list_backends cancelled by client"); + if (!spec || typeof spec.command !== "string") continue; + let resolvedCommand; + try { + resolvedCommand = await resolveBackendCommand(spec.command, { cancel }); + } catch (error) { + throw error; + } + if (!resolvedCommand) { + entries.push({ + name, + label: typeof spec.label === "string" ? spec.label : name, + command: spec.command, + available: false, + experimental: Boolean(spec.experimental), + version: null, + error: "command not found or not executable", + resumeSupported: Array.isArray(spec.resumeArgs), + notes: typeof spec.notes === "string" ? spec.notes : "", + }); + continue; + } + const check = await runCommand(resolvedCommand, ["--version"], { + timeoutMs: VERSION_CHECK_TIMEOUT_MS, + manageProcessTree: true, + shouldCancel: () => Boolean(cancel?.cancelled), + onChild: (controller) => { + if (cancel) cancel.controller = controller; + }, + }); + if (cancel?.controller) cancel.controller = null; + if (cancel?.cancelled) { + if (check.treeTerminated !== true) { + throw new Error(check.terminationError || + "backend version probe process tree could not be confirmed terminated"); + } + throw new OperationCancelledError("list_backends cancelled by client"); + } + entries.push(backendEntryFromProbe(name, spec, check)); + } + return entries; +} + +function workspaceQuarantinePath(quarantineRoot, key) { + const digest = createHash("sha256").update(key).digest("hex"); + return path.join(quarantineRoot, digest + ".quarantine"); +} + +function workspaceQuarantineRecoveryPath(quarantineRoot, key) { + return workspaceQuarantinePath(quarantineRoot, key) + ".recovery-approved"; +} + +export async function readWorkspaceQuarantine(quarantineRoot, key, options = {}) { + const fsOps = options.fsOps ?? { stat, readFile, realpath }; + const step = (operation) => interruptibleFilesystemOperation(operation, options); + const quarantinePath = workspaceQuarantinePath(quarantineRoot, key); + try { + if (process.env.NODE_ENV === "test") { + const delayMs = Number(process.env.CLI_AGENT_BRIDGE_TEST_QUARANTINE_READ_DELAY_MS ?? 0); + const targetCall = Number(process.env.CLI_AGENT_BRIDGE_TEST_QUARANTINE_READ_DELAY_CALL ?? 0); + const currentCall = ++quarantineReadTestCallCount; + if (delayMs > 0 && (!Number.isInteger(targetCall) || targetCall <= 0 || + currentCall === targetCall)) { + const startedFile = process.env.CLI_AGENT_BRIDGE_TEST_QUARANTINE_READ_STARTED_FILE; + if (startedFile) await writeFile(startedFile, "started\n").catch(() => {}); + await step(() => new Promise((resolve) => setTimeout(resolve, delayMs))); + } + } + const marker = await step(() => fsOps.stat(quarantinePath)); + let raw = null; + if (marker.isDirectory()) { + try { + raw = await step(() => fsOps.readFile( + path.join(quarantinePath, QUARANTINE_RECORD_FILE), "utf8", + )); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } else if (marker.isFile()) { + // Compatibility with quarantine marker files created by older bridges. + raw = await step(() => fsOps.readFile(quarantinePath, "utf8")); + } + let details; + try { + details = typeof raw === "string" ? JSON.parse(raw) : { error: "invalid quarantine record" }; + } catch { details = { error: "invalid quarantine record" }; } + return { quarantinePath: await step(() => fsOps.realpath(quarantinePath)), details }; + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +} + +// Recovery is an explicit, durable rename of the quarantine record rather than +// inference from an absent temporary file. The random id binds authorization +// to the exact quarantined lease and prevents a stale approval from carrying +// over to a later incident. +async function quarantineRecoveryApproved(quarantineRoot, key, owner, options = {}) { + const step = (operation) => interruptibleFilesystemOperation(operation, options); + try { + const recoveryPath = workspaceQuarantineRecoveryPath(quarantineRoot, key); + const marker = await step(() => stat(recoveryPath)); + const raw = marker.isDirectory() + ? await step(() => readFile(path.join(recoveryPath, QUARANTINE_RECORD_FILE), "utf8")) + : await step(() => readFile(recoveryPath, "utf8")); + const record = JSON.parse(raw); + return typeof owner?.quarantineId === "string" && + record?.quarantineId === owner.quarantineId; + } catch (error) { + if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError) { + throw error; + } + return false; + } +} + +async function clearQuarantineRecoveryApproval(quarantineRoot, key, options = {}) { + await interruptibleFilesystemOperation( + () => rm(workspaceQuarantineRecoveryPath(quarantineRoot, key), { recursive: true, force: true }), + options, + ); +} + +export async function markWorkspaceQuarantined( + quarantineRoot, key, details, quarantineId = randomUUID(), +) { + if (typeof quarantineId !== "string" || !quarantineId) { + throw new Error("quarantine id is unavailable"); + } + const rootMetadata = await lstat(quarantineRoot); + if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) { + throw new Error("workspace quarantine root is not a trusted lock-store directory"); + } + const quarantinePath = workspaceQuarantinePath(quarantineRoot, key); + const markerDirectoryMode = process.platform === "win32" + ? 0o700 + : rootMetadata.mode & 0o2777; + const markerFileMode = process.platform === "win32" + ? 0o600 + : rootMetadata.mode & 0o666; + const token = process.pid + "-" + randomUUID(); + const temporaryPath = quarantinePath + ".owner-" + token; + const record = { + ...details, + quarantineId, + serverPid: process.pid, + processIdentity: await cachedProcessStartIdentity(process.pid), + quarantinedAt: new Date().toISOString(), + }; + await mkdir(temporaryPath, { mode: markerDirectoryMode }); + if (process.platform !== "win32") await chmod(temporaryPath, markerDirectoryMode); + await writeFile(path.join(temporaryPath, QUARANTINE_RECORD_FILE), JSON.stringify(record), { + flag: "wx", mode: markerFileMode, + }); + if (process.platform !== "win32") { + await chmod(path.join(temporaryPath, QUARANTINE_RECORD_FILE), markerFileMode); + } + let preserveTemporary = false; + let published = false; + try { + try { + await stat(quarantinePath); + throw new Error("workspace quarantine marker already exists at " + quarantinePath); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + try { + // A populated directory is complete before its same-filesystem rename + // makes the final marker visible. Concurrent publishers cannot replace a + // non-empty winner, and no hard-link support is required. + await rename(temporaryPath, quarantinePath); + published = true; + } catch (error) { + try { + await stat(quarantinePath); + throw new Error("workspace quarantine marker already exists at " + quarantinePath); + } catch (observed) { + if (observed.code !== "ENOENT") throw observed; + } + preserveTemporary = true; + error.message += "; complete recovery record remains at " + temporaryPath; + throw error; + } + } finally { + if (!preserveTemporary) await rm(temporaryPath, { recursive: true, force: true }); + } + return { quarantinePath: await realpath(quarantinePath), quarantineId, details: record }; +} + +let linuxBootIdPromise = null; +const processIdentityCache = new Map(); +async function processStartIdentity(pid) { + if (!Number.isInteger(pid) || pid <= 0) return null; + if (process.platform === "linux") { + try { + linuxBootIdPromise ??= readFile("/proc/sys/kernel/random/boot_id", "utf8") + .then((value) => value.trim()); + const [bootId, raw] = await Promise.all([ + linuxBootIdPromise, + readFile("/proc/" + String(pid) + "/stat", "utf8"), + ]); + const close = raw.lastIndexOf(")"); + if (close < 0) return null; + const fields = raw.slice(close + 1).trim().split(/\s+/u); + if (["Z", "X", "x"].includes(fields[0])) return null; + return "linux:" + bootId + ":" + fields[19]; // field 22: start time since boot + } catch (error) { + if (error.code === "ENOENT") return null; + } + } else if (process.platform === "win32") { + const script = "$p=Get-Process -Id " + String(pid) + + " -ErrorAction SilentlyContinue; if ($null -ne $p) { $p.StartTime.ToUniversalTime().Ticks }"; + const result = await runCommand(trustedWindowsPowerShell(), [ + "-NoProfile", "-NonInteractive", "-Command", script, + ], { timeoutMs: 5_000 }); + if (result.exitCode === 0 && result.stdout.trim()) { + return "windows:" + result.stdout.trim(); + } + } else { + const result = await runCommand("ps", ["-o", "lstart=", "-p", String(pid)], { + timeoutMs: 5_000, + }); + if (result.exitCode === 0 && result.stdout.trim()) { + return process.platform + ":" + result.stdout.trim(); + } + } + return null; +} + +async function cachedProcessStartIdentity(pid) { + const cached = processIdentityCache.get(pid); + if (cached && cached.expiresAt > Date.now()) return cached.value; + const value = await processStartIdentity(pid); + processIdentityCache.set(pid, { value, expiresAt: Date.now() + 1_000 }); + return value; +} + +let serverProcessIdentityPromise = null; +async function serverProcessStartIdentity() { + serverProcessIdentityPromise ??= processStartIdentity(process.pid); + const identity = await serverProcessIdentityPromise; + if (!identity) { + serverProcessIdentityPromise = null; + throw new Error("cannot establish the bridge process start identity for workspace locking"); + } + return identity; +} + +// The in-memory queue preserves FIFO order within this server. A Git-ref CAS +// lease extends the same canonical-worktree mutex across independent stdio +// server processes without a read-then-unlink stale-owner race. +const workspaceLocks = new Map(); +const quarantinedWorkspaces = new Set(); +async function quarantineLeaseForProcessTree({ + quarantineRoot, lockKey, workspaceLease, backend, workspacePath, worktreeRoot, + terminationError, +}) { + quarantinedWorkspaces.add(lockKey); + workspaceLease.retain(); + const quarantineId = randomUUID(); + await workspaceLease.markWorkerQuarantinePending(quarantineId); + const details = { + backend, workspacePath, worktreeRoot, lockRef: workspaceLease.ref, terminationError, + }; + const quarantine = await markWorkspaceQuarantined( + quarantineRoot, lockKey, details, quarantineId, + ); + await workspaceLease.markWorkerQuarantined(quarantine.quarantineId); + quarantinedWorkspaces.delete(lockKey); + return { quarantinePath: quarantine.quarantinePath, details: quarantine.details }; +} +async function withWorkspaceLock(key, lockStoreRoot, fn, { + cancel = null, + deadline = null, + onCancelled = null, + onDeadline = null, + isUnavailable = null, + onUnavailable = null, + operatorRecoveryApproved = null, + onAcquired = null, +} = {}) { + const prev = workspaceLocks.get(key) ?? Promise.resolve(); + const prevDone = prev.catch(() => {}); + let release; + const gate = new Promise((r) => { release = r; }); + const next = prevDone.then(() => gate); + workspaceLocks.set(key, next); + let deadlineTimer = null; + const waiters = [prevDone.then(() => "acquired")]; + if (cancel) waiters.push(cancel.promise.then(() => "cancelled")); + if (deadline !== null) { + waiters.push(new Promise((resolve) => { + deadlineTimer = setTimeout(() => resolve("deadline"), Math.max(0, deadline - Date.now())); + })); + } + const localResult = await Promise.race(waiters); + clearTimeout(deadlineTimer); + if (localResult !== "acquired") { + release(); + // Keep the already-resolved gate chained behind its predecessor until the + // predecessor releases. Deleting the map entry now would let a third + // request bypass the still-running first holder. + void next.finally(() => { + if (workspaceLocks.get(key) === next) workspaceLocks.delete(key); + }); + if (localResult === "cancelled") { + return typeof onCancelled === "function" ? onCancelled() : undefined; + } + return typeof onDeadline === "function" ? onDeadline() : undefined; + } + let lease = null; + try { + if (typeof isUnavailable === "function" && await isUnavailable()) { + return typeof onUnavailable === "function" ? onUnavailable() : undefined; + } + try { + lease = await acquireGitWorkspaceLock({ + cwd: lockStoreRoot, + key, + cancel, + deadline, + operatorRecoveryApproved, + ownerIdentity: await serverProcessStartIdentity(), + processIdentityProbe: processStartIdentity, + }); + } catch (error) { + if (error instanceof WorkspaceLockCancelledError) { + return typeof onCancelled === "function" ? onCancelled() : undefined; + } + if (error instanceof WorkspaceLockDeadlineError) { + return typeof onDeadline === "function" ? onDeadline() : undefined; + } + throw error; + } + if (typeof onAcquired === "function") await onAcquired(lease); + return await fn(lease); + } catch (error) { + if (error instanceof OperationCancelledError) { + return typeof onCancelled === "function" ? onCancelled() : undefined; + } + if (error instanceof DeadlineExceededError) { + return typeof onDeadline === "function" ? onDeadline() : undefined; + } + throw error; + } finally { + try { + if (lease) { + try { + await lease.release(); + } catch (error) { + // release() persisted exact-owner recovery authorization in the + // shared lock store before attempting deletion. + throw error; + } + } + } finally { + release(); + if (workspaceLocks.get(key) === next) workspaceLocks.delete(key); + } + } +} + +function createCancellation() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + const listeners = new Set(); + return { + controller: null, + cancelled: false, + promise, + subscribe(listener) { + if (this.cancelled) { + queueMicrotask(listener); + return () => {}; + } + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + cancel() { + if (this.cancelled) return; + this.cancelled = true; + resolve(); + for (const listener of listeners) listener(); + listeners.clear(); + if (this.controller) void this.controller.terminate("cancelled"); + }, + }; +} + +function cancelledDelegation({ backend, workspacePath, worktreeRoot, spec, before = null }) { + return { + ok: false, + error: "delegation cancelled by client before the worker started", + backend, + workspacePath, + worktreeRoot, + exitCode: null, + timedOut: false, + killed: false, + cancelled: true, + treeTerminated: true, + outputTail: "", + stderrTail: "", + gitBefore: before, + git: before, + commits: null, + experimental: Boolean(spec.experimental), + }; +} + +function lockDeadlineDelegation({ + backend, + workspacePath, + worktreeRoot, + spec, + error = "delegation timed out while waiting for the workspace lock; the worker never started", +}) { + return { + ok: false, + error, + backend, + workspacePath, + worktreeRoot, + exitCode: null, + timedOut: true, + killed: false, + cancelled: false, + treeTerminated: true, + outputTail: "", + stderrTail: "", + gitBefore: null, + git: null, + commits: null, + experimental: Boolean(spec.experimental), + }; +} + +function quarantinedDelegation({ backend, workspacePath, worktreeRoot, spec }, sharedQuarantine = null) { + return { + ok: false, + error: "this workspace is quarantined because a previous worker or repository helper process tree could not be confirmed terminated; inspect leftover processes, then rename the reported quarantine file with the .recovery-approved suffix", + backend, workspacePath, worktreeRoot, exitCode: null, timedOut: false, killed: false, cancelled: false, + treeTerminated: false, outputTail: "", stderrTail: "", + gitBefore: null, git: null, commits: null, + quarantinePath: sharedQuarantine?.quarantinePath ?? "", + quarantine: sharedQuarantine?.details ?? null, + experimental: Boolean(spec.experimental), + }; +} + +function cancelledWorkspaceStatus(id, { workspacePath = "", worktreeRoot = "" } = {}) { + const out = { + ok: false, + error: "workspace status cancelled by client", + cancelled: true, + workspacePath, + worktreeRoot, + git: null, + }; + return jsonRpcResult(id, { + content: [{ type: "text", text: textResult("Workspace Status", out) }], + structuredContent: out, + isError: true, + }); +} + +function quarantinedWorkspaceStatus(id, { workspacePath = "", worktreeRoot = "" } = {}, sharedQuarantine = null) { + const out = { + ok: false, + error: "workspace status is unavailable because an earlier worker or repository helper process tree could not be confirmed terminated; after inspection, rename quarantinePath with the .recovery-approved suffix", + cancelled: false, + workspacePath, + worktreeRoot, + git: null, + quarantinePath: sharedQuarantine?.quarantinePath ?? "", + quarantine: sharedQuarantine?.details ?? null, + }; + return jsonRpcResult(id, { + content: [{ type: "text", text: textResult("Workspace Status", out) }], + structuredContent: out, + isError: true, + }); +} + +async function delegateTask(rawArgs, cancel) { + if (!supportsReliableProcessContainment()) { + return { + ok: false, + error: unsupportedPlatformMessage("delegate_task"), + backend: typeof rawArgs?.backend === "string" ? rawArgs.backend.trim() : "", + workspacePath: typeof rawArgs?.workspacePath === "string" ? rawArgs.workspacePath : "", + worktreeRoot: "", + exitCode: null, + timedOut: false, + killed: false, + cancelled: false, + treeTerminated: true, + outputTail: "", + stderrTail: "", + gitBefore: null, + git: null, + commits: null, + experimental: false, + }; + } + const hasTimeout = Boolean(rawArgs && Object.prototype.hasOwnProperty.call(rawArgs, "timeoutMs")); + if (hasTimeout && (!Number.isInteger(rawArgs.timeoutMs) || + rawArgs.timeoutMs < MIN_TIMEOUT_MS || rawArgs.timeoutMs > MAX_TIMEOUT_MS)) { + throw new InvalidArgumentsError( + "timeoutMs must be an integer between " + String(MIN_TIMEOUT_MS) + + " and " + String(MAX_TIMEOUT_MS) + " milliseconds", + ); + } + const timeoutMs = hasTimeout ? rawArgs.timeoutMs : DEFAULT_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; + const requestedBackend = typeof rawArgs?.backend === "string" ? rawArgs.backend.trim() : ""; + let backends; + try { + backends = await loadBackends({ cancel, deadline }); + } catch (error) { + const pending = { + backend: requestedBackend, + workspacePath: "", + worktreeRoot: "", + spec: {}, + }; + if (error instanceof OperationCancelledError) return cancelledDelegation(pending); + if (error instanceof DeadlineExceededError) { + return lockDeadlineDelegation({ + ...pending, + error: "delegation timed out while loading backend configuration; the worker never started", + }); + } + throw error; + } + if (!rawArgs || typeof rawArgs.backend !== "string" || !rawArgs.backend.trim()) { + throw new InvalidArgumentsError("backend must be a non-empty string"); + } + const backend = rawArgs.backend.trim(); + const spec = backends[backend]; + if (!spec || typeof spec.command !== "string") { + throw new InvalidArgumentsError( + "unknown backend \"" + backend + "\"; use list_backends to see configured backends", + ); + } + if (typeof rawArgs.task !== "string" || !rawArgs.task.trim()) { + throw new InvalidArgumentsError("task must be a non-empty string"); + } + let backendCommand; + try { + backendCommand = await resolveBackendCommand(spec.command, { cancel, deadline }); + } catch (error) { + if (error instanceof OperationCancelledError) { + return cancelledDelegation({ backend, workspacePath: "", worktreeRoot: "", spec }); + } + if (error instanceof DeadlineExceededError) { + return lockDeadlineDelegation({ + backend, + workspacePath: "", + worktreeRoot: "", + spec, + error: "delegation timed out while resolving the backend command; the worker never started", + }); + } + throw error; + } + if (!backendCommand) { + return { + ok: false, + error: "backend \"" + backend + "\" command was not found or is not executable", + backend, + workspacePath: typeof rawArgs.workspacePath === "string" ? rawArgs.workspacePath : "", + worktreeRoot: "", + exitCode: null, + timedOut: false, + killed: false, + cancelled: false, + treeTerminated: true, + outputTail: "", + stderrTail: "", + gitBefore: null, + git: null, + commits: null, + experimental: Boolean(spec.experimental), + }; + } + let workspacePath = ""; + let worktreeRoot = ""; + let gitCommonDir = ""; + let lockStoreRoot = ""; + let quarantineRoot = ""; + let repositoryAccess = null; + try { + if (cancel?.cancelled) { + return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); + } + workspacePath = await validateWorkspace(rawArgs.workspacePath, { cancel, deadline }); + if (Date.now() >= deadline) throw new DeadlineExceededError("delegation deadline exceeded"); + await requireGitRepo(workspacePath, { cancel, deadline }); + worktreeRoot = await gitWorktreeRoot(workspacePath, { cancel, deadline }); + gitCommonDir = await gitCommonDirectory(workspacePath, { cancel, deadline }); + repositoryAccess = await openRepositoryAccess(gitCommonDir, { cancel, deadline }); + const lockStore = await ensureWorkspaceLockStore(repositoryAccess.commonDir, { cancel, deadline }); + lockStoreRoot = lockStore.root; + quarantineRoot = lockStore.quarantineRoot; + repositoryAccess.key = repositoryLockKey(gitCommonDir, lockStore.repositoryId); + } catch (error) { + await repositoryAccess?.close().catch(() => {}); + if (error instanceof OperationCancelledError) { + return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); + } + if (error instanceof DeadlineExceededError) { + return lockDeadlineDelegation({ + backend, + workspacePath, + worktreeRoot, + spec, + error: "delegation timed out while identifying the Git worktree; the worker never started", + }); + } + throw error; + } + const lockKey = repositoryAccess.key; + try { + const existingQuarantine = await readWorkspaceQuarantine( + quarantineRoot, lockKey, { cancel, deadline }, + ); + if (quarantinedWorkspaces.has(lockKey) || existingQuarantine) { + return quarantinedDelegation({ backend, workspacePath, worktreeRoot, spec }, existingQuarantine); + } + let observedQuarantine = null; + return await withWorkspaceLock(lockKey, lockStoreRoot, async (workspaceLease) => { + const sharedQuarantine = await readWorkspaceQuarantine( + quarantineRoot, lockKey, { cancel, deadline }, + ); + if (quarantinedWorkspaces.has(lockKey) || sharedQuarantine) { + return quarantinedDelegation( + { backend, workspacePath, worktreeRoot, spec }, sharedQuarantine, + ); + } + if (cancel && cancel.cancelled) { + return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); + } + let gitProcessQuarantine = null; + const quarantineGitProcessTree = async ({ label, terminationError }) => { + gitProcessQuarantine ??= await quarantineLeaseForProcessTree({ + quarantineRoot, + lockKey, + workspaceLease, + backend: label, + workspacePath, + worktreeRoot, + terminationError, + }); + return gitProcessQuarantine; + }; + const allowDirty = rawArgs.allowDirty === true; + let before; + try { + before = await gitSnapshot(worktreeRoot, { + cancel, deadline, ownLockRef: workspaceLease.ref, lockStoreRoot, + onUnconfirmedProcessTree: quarantineGitProcessTree, + }); + } catch (error) { + if (error instanceof GitProcessTreeUnconfirmedError) { + return quarantinedDelegation( + { backend, workspacePath, worktreeRoot, spec }, error.quarantine, + ); + } + if (error instanceof OperationCancelledError) { + return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); + } + if (error instanceof DeadlineExceededError) { + return { + ok: false, + error: "delegation timed out during the preflight git snapshot; the worker never started", + backend, workspacePath, worktreeRoot, exitCode: null, timedOut: true, killed: false, cancelled: false, + treeTerminated: true, outputTail: "", stderrTail: "", + gitBefore: null, git: null, commits: null, + experimental: Boolean(spec.experimental), + }; + } + throw error; + } + if (cancel && cancel.cancelled) { + return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec, before }); + } + if (!allowDirty && before.statusShort) { + return { + ok: false, + error: "working tree is dirty; review current changes first or set allowDirty=true deliberately", + backend, workspacePath, worktreeRoot, exitCode: null, timedOut: false, killed: false, cancelled: false, + treeTerminated: true, + outputTail: "", stderrTail: "", + gitBefore: before, git: before, commits: null, + experimental: Boolean(spec.experimental), + }; + } + + let template; + const resumeRequested = typeof rawArgs.resumeSessionId === "string" && rawArgs.resumeSessionId.trim(); + if (resumeRequested && !Array.isArray(spec.resumeArgs)) { + return { + ok: false, + error: "backend \"" + backend + "\" does not support resuming sessions", + backend, workspacePath, worktreeRoot, exitCode: null, timedOut: false, killed: false, cancelled: false, + treeTerminated: true, outputTail: "", stderrTail: "", + gitBefore: before, git: before, commits: null, + experimental: Boolean(spec.experimental), + }; + } + if (resumeRequested) { + template = spec.resumeArgs; + } else if (Array.isArray(spec.buildArgs)) { + template = spec.buildArgs; + } else { + return { + ok: false, + error: "backend \"" + backend + "\" has no command template configured", + backend, workspacePath, worktreeRoot, exitCode: null, timedOut: false, killed: false, cancelled: false, + treeTerminated: true, + outputTail: "", stderrTail: "", + gitBefore: before, git: before, commits: null, + experimental: Boolean(spec.experimental), + }; + } + + const args = substituteArgs(template, rawArgs.task.trim(), rawArgs.resumeSessionId ?? ""); + + // Cancellation can arrive while this request waits for the mutex or while + // the read-only preflight snapshot runs. Never spawn after that signal. + if (cancel && cancel.cancelled) { + return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec, before }); + } + + // The lease state update shares the request's cancellation/deadline so a + // hung reference-transaction hook cannot pin the request here. + try { + await workspaceLease.markWorkerStarting({ cancel, deadline }); + } catch (error) { + if (error instanceof WorkspaceLockCancelledError) { + return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec, before }); + } + if (error instanceof WorkspaceLockDeadlineError) { + return { + ok: false, + error: "delegation timed out after preflight; the worker never started", + backend, workspacePath, worktreeRoot, exitCode: null, timedOut: true, killed: false, cancelled: false, + treeTerminated: true, outputTail: "", stderrTail: "", + gitBefore: before, git: before, commits: null, + experimental: Boolean(spec.experimental), + }; + } + throw error; + } + let remaining = deadline - Date.now(); + if (remaining <= 0) { + return { + ok: false, + error: "delegation timed out after preflight; the worker never started", + backend, workspacePath, worktreeRoot, exitCode: null, timedOut: true, killed: false, cancelled: false, + treeTerminated: true, outputTail: "", stderrTail: "", + gitBefore: before, git: before, commits: null, + experimental: Boolean(spec.experimental), + }; + } + // Keep this check immediately adjacent to the backend launch. The request + // may have been cancelled during preflight or argument preparation. + if (cancel?.cancelled) { + return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec, before }); + } + let workerController = null; + let workerFinished = false; + let ownershipLostError = null; + let ownershipTermination = Promise.resolve(); + let ownershipTerminationStarted = false; + let ownershipTerminationError = null; + const recordOwnershipLoss = (error) => { + ownershipLostError ??= error; + if (workerController && !workerFinished && !ownershipTerminationStarted) { + ownershipTerminationStarted = true; + ownershipTermination = workerController.terminate("workspace-lock-lost").catch((terminationError) => { + ownershipTerminationError ??= terminationError; + }); + } + return ownershipTermination; + }; + void workspaceLease.lost.then(recordOwnershipLoss).catch((error) => { + ownershipLostError ??= error; + }); + let workerLockUpdate = Promise.resolve(); + let gitProvenance; + try { + gitProvenance = await createBackendGitProvenance(process.env, { cancel, deadline }); + } catch (error) { + if (error instanceof OperationCancelledError) { + return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec, before }); + } + if (error instanceof DeadlineExceededError) { + return { + ok: false, + error: "delegation timed out while preparing Git provenance; the worker never started", + backend, workspacePath, worktreeRoot, exitCode: null, timedOut: true, killed: false, cancelled: false, + treeTerminated: true, outputTail: "", stderrTail: "", + gitBefore: before, git: before, commits: null, + experimental: Boolean(spec.experimental), + }; + } + throw error; + } + remaining = deadline - Date.now(); + if (cancel?.cancelled) { + await cleanupBackendGitProvenance(gitProvenance); + return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec, before }); + } + if (remaining <= 0) { + await cleanupBackendGitProvenance(gitProvenance); + return { + ok: false, + error: "delegation timed out after preflight; the worker never started", + backend, workspacePath, worktreeRoot, exitCode: null, timedOut: true, killed: false, cancelled: false, + treeTerminated: true, outputTail: "", stderrTail: "", + gitBefore: before, git: before, commits: null, + experimental: Boolean(spec.experimental), + }; + } + let fetchProvenanceTips = { uncertain: false, sawFetch: false }; + let result; + let workerRunFailed = false; + let workerRunError = null; + try { + result = await runCommand(backendCommand, args, { + cwd: workspacePath, + // Git's private per-run Trace2 stream detects target-repository + // fetch/pull commands without trusting the overwritable FETCH_HEAD. + env: gitProvenance.env, + timeoutMs: remaining, + manageProcessTree: true, + shouldCancel: () => Boolean(cancel && cancel.cancelled), + onChild: (controller) => { + workerController = controller; + if (cancel) cancel.controller = controller; + if (ownershipLostError) void recordOwnershipLoss(ownershipLostError); + workerLockUpdate = workspaceLease.markWorkerRunning(controller.child.pid, { cancel, deadline }) + .catch((error) => { + // Interruption of the state update is expected during cancellation + // or timeout: the worker lifecycle itself is managed by terminate(). + if (error instanceof WorkspaceLockCancelledError || + error instanceof WorkspaceLockDeadlineError) { + return; + } + return recordOwnershipLoss(error); + }); + }, + }); + } catch (error) { + workerRunFailed = true; + workerRunError = error; + } finally { + // Once runCommand settles it has completed tree cleanup. Clear the + // numeric-PID controller before any later I/O can fail or yield. + workerFinished = true; + if (cancel?.controller === workerController) cancel.controller = null; + workerController = null; + } + if (workerRunFailed) { + await cleanupBackendGitProvenance(gitProvenance); + throw workerRunError; + } + let quarantinePath = ""; + try { + // The controller is already cleared above; only the ref-state update may + // still be settling here. Quarantine an unconfirmed tree before touching + // its trace, which a surviving descendant may still be writing. + await workerLockUpdate; + if (!result.treeTerminated) { + quarantinedWorkspaces.add(lockKey); + workspaceLease.retain(); + const quarantineId = randomUUID(); + await workspaceLease.markWorkerQuarantinePending(quarantineId); + // The pending CAS above makes a crash during marker publication + // unreclaimable. Only a complete, atomically published record advances + // the lease to the operator-recoverable quarantined state. + const quarantine = await markWorkspaceQuarantined(quarantineRoot, lockKey, { + backend, + workspacePath, + worktreeRoot, + lockRef: workspaceLease.ref, + terminationError: result.terminationError, + }, quarantineId); + quarantinePath = quarantine.quarantinePath; + await workspaceLease.markWorkerQuarantined(quarantine.quarantineId); + // The shared marker is now authoritative and can be explicitly renamed + // by an operator after inspection; retain the local fallback only when + // writing that marker failed. + quarantinedWorkspaces.delete(lockKey); + } else if (!ownershipLostError) { + try { + await workspaceLease.markWorkerIdle({ cancel, deadline }); + } catch (error) { + if (!(error instanceof WorkspaceLockCancelledError) && + !(error instanceof WorkspaceLockDeadlineError)) { + await recordOwnershipLoss(error); + } + } + } + if (ownershipLostError) { + await ownershipTermination; + if (ownershipTerminationError && ownershipLostError.cause === undefined) { + ownershipLostError.cause = ownershipTerminationError; + } + throw ownershipLostError; + } + if (result.treeTerminated) { + try { + fetchProvenanceTips = await readBackendGitProvenance( + gitProvenance, worktreeRoot, { cancel, deadline }, + ); + } catch { + // A malformed, truncated, or missing trace cannot justify commit + // attribution. Preserve the completed worker result and disclose the + // uncertainty instead of letting trace I/O bypass workspace safety. + fetchProvenanceTips = { uncertain: true, sawFetch: false }; + } + } + } finally { + // Cleanup is privacy hygiene, not a workspace-safety gate. In particular, + // it must never replace a durable quarantine result with a released lease. + await cleanupBackendGitProvenance(gitProvenance); + } + let after = null; + let commits = null; + let postRunDeadlineExceeded = false; + if (result.treeTerminated) { + try { + after = await gitSnapshot(worktreeRoot, { + cancel, + deadline, + ownLockRef: workspaceLease.ref, + lockStoreRoot, + concurrencyHistoryBaseline: before[LOCK_HISTORY_REFS], + onUnconfirmedProcessTree: quarantineGitProcessTree, + }); + Object.defineProperty(after, FETCH_PROVENANCE_TIPS, { value: fetchProvenanceTips }); + commits = await committedDelta(worktreeRoot, before, after, { cancel, deadline }); + } catch (error) { + if (error instanceof GitProcessTreeUnconfirmedError) { + result.treeTerminated = false; + result.terminationError = error.terminationError; + quarantinePath = error.quarantine?.quarantinePath ?? ""; + after = null; + } else if (error instanceof OperationCancelledError) { + // The worker is already stopped; report cancellation without a + // misleading partial snapshot assembled from interrupted Git calls. + after = null; + } else if (error instanceof DeadlineExceededError) { + postRunDeadlineExceeded = true; + after = null; + } else { + throw error; + } + } + } + // Linked worktrees of one repository serialize per worktree only. If any + // other delegation held an active lease in the same repository during our + // before/after snapshots, ref movements may include its commits: disclose + // the overlap instead of presenting attribution as exact. + const repositoryConcurrency = Boolean( + (before.concurrentDelegations ?? 0) > 0 || (after?.concurrentDelegations ?? 0) > 0, + ); + let error = ""; + if (!result.treeTerminated) { + error = "backend or Git snapshot process tree could not be confirmed terminated; the shared workspace quarantine remains until an operator checks for leftovers and renames quarantinePath with the .recovery-approved suffix"; + } else if (cancel && cancel.cancelled) { + error = "delegation cancelled by client; post-run snapshot may be unavailable"; + } else if (result.timedOut) { + error = "backend \"" + backend + "\" timed out after " + timeoutMs + " ms" + (result.killed ? " and was force-killed" : ""); + } else if (postRunDeadlineExceeded) { + error = "backend exited, but the post-run Git snapshot or commit attribution exceeded the overall deadline"; + } else if (result.orphanedProcesses) { + error = "backend exited while descendant processes were still running; the bridge terminated the remaining process tree"; + } else if (result.errorMessage) { + error = "backend \"" + backend + "\" failed to start: " + result.errorMessage; + } else if (result.exitCode !== 0) { + error = "backend \"" + backend + "\" exited with code " + String(result.exitCode); + } + + return { + ok: !error, + error, + backend, + workspacePath, + worktreeRoot, + exitCode: result.exitCode, + timedOut: result.timedOut, + postRunDeadlineExceeded, + killed: result.killed, + cancelled: Boolean(cancel && cancel.cancelled), + orphanedProcesses: result.orphanedProcesses, + treeTerminated: result.treeTerminated, + terminationError: result.terminationError, + quarantinePath, + repositoryConcurrency, + outputTail: tail(result.stdout, RAW_TAIL_CHARS), + stderrTail: tail(result.stderr, RAW_TAIL_CHARS), + outputTruncated: Boolean(result.stdoutTruncated), + stderrTruncated: Boolean(result.stderrTruncated), + gitBefore: before, + git: after, + commits, + experimental: Boolean(spec.experimental), + }; + }, { + cancel, + deadline, + onCancelled: () => cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }), + onDeadline: () => lockDeadlineDelegation({ backend, workspacePath, worktreeRoot, spec }), + operatorRecoveryApproved: (owner) => quarantineRecoveryApproved( + quarantineRoot, lockKey, owner, { cancel, deadline }, + ), + onAcquired: () => clearQuarantineRecoveryApproval( + quarantineRoot, lockKey, { cancel, deadline }, + ), + isUnavailable: async () => { + observedQuarantine = await readWorkspaceQuarantine( + quarantineRoot, lockKey, { cancel, deadline }, + ); + return quarantinedWorkspaces.has(lockKey) || Boolean(observedQuarantine); + }, + onUnavailable: () => quarantinedDelegation( + { backend, workspacePath, worktreeRoot, spec }, observedQuarantine, + ), + }); + } catch (error) { + if (error instanceof OperationCancelledError) { + return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); + } + if (error instanceof DeadlineExceededError) { + return lockDeadlineDelegation({ backend, workspacePath, worktreeRoot, spec }); + } + throw error; + } finally { + await repositoryAccess.close().catch(() => {}); + } +} + +function textResult(header, obj) { + const lines = ["# " + header, ""]; + for (const [key, value] of Object.entries(obj)) { + if (["outputTail", "stderrTail", "git", "gitBefore", "commits"].includes(key)) continue; + lines.push("- " + key + ": " + String(value ?? "")); + } + const gitBlock = (label, git) => { + if (!git) return; + lines.push("", "## " + label + " git status --short", "", "~~~text", git.statusShort || "(clean)", "~~~"); + lines.push("", "## " + label + " git diff stat", "", "~~~text", git.diffStat || "(empty)", "~~~"); + lines.push("", "## " + label + " changed files", "", "~~~text", (git.changedFiles ?? []).join("\n") || "(none)", "~~~"); + lines.push("", "## " + label + " HEAD", "", "~~~text", git.head || "(unborn)", "~~~"); + lines.push("", "## " + label + " symbolic HEAD", "", "~~~text", git.headRef || "(detached/unborn)", "~~~"); + }; gitBlock("before", obj.gitBefore); + gitBlock("after", obj.git); + if (obj.commits) { + if (obj.commits.refsChanged?.length) { + lines.push("", "## refs changed by the worker", "", "~~~text", + obj.commits.refsChanged.map((item) => + item.ref + " " + (item.before || "(absent)") + " -> " + (item.after || "(deleted)"), + ).join("\n"), "~~~"); + } + const commitsHeading = obj.repositoryConcurrency + ? "## commits attributed to the worker (other delegations were active in this repository; attribution may overlap)" + : "## commits made by the worker"; + lines.push("", commitsHeading, "", "~~~text", obj.commits.log || "(none)", "~~~"); + lines.push("", "## commit diff stat", "", "~~~text", obj.commits.diffStat || "(empty)", "~~~"); + } + if (obj.repositoryConcurrency) { + lines.push( + "", + "## attribution note", + "", + "other delegations were active in this repository during the run; commits and ref changes may overlap those workers", + ); + } + if (obj.outputTail) lines.push("", "## output tail", "", "~~~text", obj.outputTail, "~~~"); + if (obj.stderrTail) lines.push("", "## stderr tail", "", "~~~text", obj.stderrTail, "~~~"); + if (obj.error) lines.push("", "## error", "", obj.error); + return lines.join("\n"); +} + +function jsonRpcResult(id, result) { return { jsonrpc: "2.0", id, result }; } +function jsonRpcError(id, code, message) { return { jsonrpc: "2.0", id, error: { code, message } }; } + +// In-flight cancellable tool requests, keyed by the original JSON-RPC id type, +// so notifications/cancelled can interrupt workers, lock waits, and snapshots. +const activeRequests = new Map(); +let shutdownRequested = false; + +function trackActiveRequest(id, cancel) { + let resolveDone; + const entry = { cancel, done: new Promise((resolve) => { resolveDone = resolve; }) }; + activeRequests.set(id, entry); + return () => { + if (activeRequests.get(id) === entry) activeRequests.delete(id); + resolveDone(); + }; +} + +async function terminateActiveRequests(reason = "shutdown") { + const entries = [...activeRequests.values()]; + const waits = []; + for (const { cancel } of entries) { + const controller = cancel.controller; + cancel.cancel(); + if (controller) waits.push(controller.terminate(reason)); + } + await Promise.allSettled(waits); + // Let each request unwind its lock/snapshot finally blocks before the server + // exits, avoiding an unnecessary stale cross-process lock after clean shutdown. + await Promise.allSettled(entries.map((entry) => entry.done)); + // An interrupted provenance setup may have a native filesystem operation + // completing after its request returns. Give its known-root cleanup a short, + // bounded shutdown window without letting a stalled filesystem pin exit. + if (backgroundFinalizers.size > 0) { + let timer = null; + try { + await Promise.race([ + Promise.allSettled([...backgroundFinalizers]), + new Promise((resolve) => { + timer = setTimeout(resolve, TRACE_CLEANUP_TIMEOUT_MS); + }), + ]); + } finally { + clearTimeout(timer); + } + } +} + +function installShutdownHandlers(stdin, stdout = process.stdout) { + const shutdown = (exitCode) => { + if (shutdownRequested) return; + // Close the dispatch gate before snapshotting activeRequests. Otherwise a + // request arriving while termination is in progress would not be included + // in the awaited snapshot and process.exit could skip its lease cleanup. + shutdownRequested = true; + stdin.pause?.(); + void terminateActiveRequests().finally(() => process.exit(exitCode)); + }; + process.once("SIGTERM", () => shutdown(0)); + process.once("SIGINT", () => shutdown(130)); + stdin.once("end", () => shutdown(0)); + stdin.once("close", () => shutdown(0)); + // A disconnected MCP host turns the next response write into EPIPE. Treat + // that as the same awaited shutdown as stdin closure so active workers are + // terminated and their lease finalizers finish before this process exits. + stdout.once("error", () => shutdown(1)); + // Last-chance best effort. On POSIX, controller.terminate signals the + // detached process group synchronously before its first await. + process.once("exit", () => { + for (const { cancel } of activeRequests.values()) cancel.cancel(); + }); + return shutdown; +} + +async function handleMessage(message) { + if (!message || typeof message !== "object" || message.jsonrpc !== "2.0") { + return jsonRpcError(null, -32600, "Invalid JSON-RPC request"); + } + if (message.method === "notifications/cancelled") { + const requestId = message.params?.requestId ?? message.params?.id; + const entry = activeRequests.get(requestId); + if (entry && entry.cancel) { + entry.cancel.cancel(); + } + return null; + } + if (message.id === undefined) return null; // other notification + + try { + switch (message.method) { + case "initialize": + return jsonRpcResult(message.id, { + // Negotiate honestly: this server implements exactly one protocol + // version, so it always reports that version rather than echoing an + // unsupported client request. + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: SERVER_NAME, title: "CLI Agent Bridge", version: SERVER_VERSION }, + instructions: + "Delegate coding tasks to locally installed coding CLIs. Prefer workspace_status first, then delegate_task, then review the returned git snapshot. Never put credentials in task text.", + }); + case "ping": + return jsonRpcResult(message.id, {}); + case "tools/list": + return jsonRpcResult(message.id, { tools: TOOLS }); + case "tools/call": { + const params = message.params ?? {}; + if (typeof params.name !== "string") return jsonRpcError(message.id, -32602, "tools/call requires params.name"); + const args = params.arguments ?? {}; + if (params.name === "list_backends") { + const cancel = createCancellation(); + const finishRequest = trackActiveRequest(message.id, cancel); + try { + const entries = await listBackends(cancel); + const lines = ["# Delegation Backends", ""]; + for (const e of entries) { + lines.push("- " + e.name + " (" + e.label + "): " + (e.available ? "available" : "unavailable") + (e.experimental ? " [experimental]" : "")); + if (e.version) lines.push(" version: " + e.version); + if (e.error) lines.push(" error: " + e.error); + if (e.notes) lines.push(" note: " + e.notes); + } + return jsonRpcResult(message.id, { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: { backends: entries }, + }); + } catch (error) { + if (error instanceof OperationCancelledError) { + return jsonRpcError(message.id, -32800, "list_backends cancelled by client"); + } + throw error; + } finally { + finishRequest(); + } + } + if (params.name === "workspace_status") { + if (!supportsReliableProcessContainment()) { + const out = { + ok: false, + error: unsupportedPlatformMessage("workspace_status"), + cancelled: false, + workspacePath: typeof args.workspacePath === "string" ? args.workspacePath : "", + worktreeRoot: "", + git: null, + }; + return jsonRpcResult(message.id, { + content: [{ type: "text", text: textResult("Workspace Status", out) }], + structuredContent: out, + isError: true, + }); + } + const cancel = createCancellation(); + const finishRequest = trackActiveRequest(message.id, cancel); + let workspacePath = ""; + let worktreeRoot = ""; + let gitCommonDir = ""; + let lockStoreRoot = ""; + let quarantineRoot = ""; + let repositoryAccess = null; + try { + workspacePath = await validateWorkspace(args.workspacePath, { cancel }); + if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath }); + try { + await requireGitRepo(workspacePath, { cancel }); + if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath }); + worktreeRoot = await gitWorktreeRoot(workspacePath, { cancel }); + gitCommonDir = await gitCommonDirectory(workspacePath, { cancel }); + repositoryAccess = await openRepositoryAccess(gitCommonDir, { cancel }); + const lockStore = await ensureWorkspaceLockStore(repositoryAccess.commonDir, { cancel }); + lockStoreRoot = lockStore.root; + quarantineRoot = lockStore.quarantineRoot; + repositoryAccess.key = repositoryLockKey(gitCommonDir, lockStore.repositoryId); + } catch (error) { + if (error instanceof OperationCancelledError) { + return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); + } + throw error; + } + if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); + const lockKey = repositoryAccess.key; + const existingQuarantine = await readWorkspaceQuarantine( + quarantineRoot, lockKey, { cancel }, + ); + if (quarantinedWorkspaces.has(lockKey) || existingQuarantine) { + return quarantinedWorkspaceStatus( + message.id, { workspacePath, worktreeRoot }, existingQuarantine, + ); + } + let observedQuarantine = null; + return await withWorkspaceLock(lockKey, lockStoreRoot, async (workspaceLease) => { + const sharedQuarantine = await readWorkspaceQuarantine( + quarantineRoot, lockKey, { cancel }, + ); + if (quarantinedWorkspaces.has(lockKey) || sharedQuarantine) { + return quarantinedWorkspaceStatus( + message.id, { workspacePath, worktreeRoot }, sharedQuarantine, + ); + } + let gitProcessQuarantine = null; + const quarantineGitProcessTree = async ({ label, terminationError }) => { + gitProcessQuarantine ??= await quarantineLeaseForProcessTree({ + quarantineRoot, + lockKey, + workspaceLease, + backend: label, + workspacePath, + worktreeRoot, + terminationError, + }); + return gitProcessQuarantine; + }; + try { + const git = await gitSnapshot(worktreeRoot, { + cancel, ownLockRef: workspaceLease.ref, lockStoreRoot, + onUnconfirmedProcessTree: quarantineGitProcessTree, + }); + if (cancel.cancelled) { + return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); + } + return jsonRpcResult(message.id, { + content: [{ type: "text", text: textResult("Workspace Status", { workspacePath, worktreeRoot, git }) }], + structuredContent: { ok: true, workspacePath, worktreeRoot, git }, + }); + } catch (error) { + if (error instanceof GitProcessTreeUnconfirmedError) { + return quarantinedWorkspaceStatus( + message.id, { workspacePath, worktreeRoot }, error.quarantine, + ); + } + if (error instanceof OperationCancelledError) { + return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); + } + throw error; + } + }, { + cancel, + onCancelled: () => cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }), + operatorRecoveryApproved: (owner) => quarantineRecoveryApproved( + quarantineRoot, lockKey, owner, { cancel }, + ), + onAcquired: () => clearQuarantineRecoveryApproval( + quarantineRoot, lockKey, { cancel }, + ), + isUnavailable: async () => { + observedQuarantine = await readWorkspaceQuarantine( + quarantineRoot, lockKey, { cancel }, + ); + return quarantinedWorkspaces.has(lockKey) || Boolean(observedQuarantine); + }, + onUnavailable: () => quarantinedWorkspaceStatus( + message.id, { workspacePath, worktreeRoot }, observedQuarantine, + ), + }); + } catch (error) { + if (error instanceof OperationCancelledError) { + return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); + } + throw error; + } finally { + await repositoryAccess?.close().catch(() => {}); + finishRequest(); + } + } + if (params.name === "delegate_task") { + const cancel = createCancellation(); + const finishRequest = trackActiveRequest(message.id, cancel); + try { + const out = await delegateTask(args, cancel); + return jsonRpcResult(message.id, { + content: [{ type: "text", text: textResult("Delegated Task Result", out) }], + structuredContent: out, + isError: !out.ok, + }); + } finally { + finishRequest(); + } + } + return jsonRpcError(message.id, -32602, "Unknown tool: " + params.name); + } + default: + return jsonRpcError(message.id, -32601, "Method not found: " + String(message.method)); + } + } catch (error) { + return jsonRpcError( + message.id, + error instanceof InvalidArgumentsError ? -32602 : -32603, + error.message, + ); + } +} + +export function startStdioServer({ + stdin = process.stdin, + stdout = process.stdout, + onOversizedLine = () => stdin.destroy?.(), +} = {}) { + stdin.setEncoding("utf8"); + let buffer = ""; + stdin.on("data", (chunk) => { + if (shutdownRequested) return; + buffer += chunk; + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + if (shutdownRequested) { + buffer = ""; + break; + } + if (newlineIndex > MAX_JSON_RPC_LINE_CHARS) { + buffer = ""; + stdout.write(JSON.stringify(jsonRpcError( + null, -32700, "JSON-RPC request line exceeds the configured size limit", + )) + "\n"); + onOversizedLine(); + return; + } + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + newlineIndex = buffer.indexOf("\n"); + if (!line) continue; + let message; + try { + message = JSON.parse(line); + } catch { + stdout.write(JSON.stringify(jsonRpcError(null, -32700, "Parse error")) + "\n"); + continue; + } + handleMessage(message).then((response) => { + if (response) stdout.write(JSON.stringify(response) + "\n"); + }).catch((error) => { + stdout.write(JSON.stringify(jsonRpcError(null, -32603, error.message)) + "\n"); + }); + } + if (buffer.length > MAX_JSON_RPC_LINE_CHARS) { + buffer = ""; + stdout.write(JSON.stringify(jsonRpcError( + null, -32700, "JSON-RPC request line exceeds the configured size limit", + )) + "\n"); + onOversizedLine(); + } + }); +} + +if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) { + const shutdown = installShutdownHandlers(process.stdin, process.stdout); + startStdioServer({ + onOversizedLine: () => shutdown(1), + }); +} diff --git a/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md new file mode 100644 index 0000000..d4dfd36 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -0,0 +1,89 @@ +--- +name: cli-agent-bridge +description: Delegate a coding task from MiniMax Code to a locally installed coding CLI (Claude Code, Codex, Kimi Code, ZCode, or DSH). Use this Skill whenever the user asks to offload implementation to another coding agent, cross-check work with a second agent, parallelize independent subtasks, or run a long self-contained task outside the current context. +--- + +# CLI Agent Bridge + +MiniMax Code stays the orchestrator. The other coding CLIs run as headless workers +inside the target git repository, and their results come back as a git diff for review. + +## When to use + +- The user names another CLI explicitly (for example: delegate this to codex). +- A task is long and self-contained and should not fill the current context. +- Independent subtasks in separate clones can run in parallel across different CLIs; + worktrees sharing one Git common directory queue behind each other. +- The user wants a second opinion or a cross-check from another agent. + +## Workflow + +1. Run workspace_status with the workspace path and confirm the working tree is clean. +2. Pick a backend from list_backends and confirm it is available on this machine. +3. Run delegate_task with a self-contained task, the workspace path, and the backend name. + Delegations whose worktrees share one Git common directory are serialized across bridge server + processes, so linked worktrees and separate MCP clients cannot interleave shared-ref snapshots. +4. Review the returned result: the before and after git snapshots (status, diff stat, changed + files including staged and new files), changed refs and the commits block when the worker committed, the + output and stderr tails, and the exit code. A failed, timed-out, or cancelled run reports + ok=false (and isError=true at the protocol level); never treat such a result as success. + If repositoryConcurrency is true, an older bridge instance or external writer overlapped the + snapshot; treat the commits block as attributed rather than exclusive output. +5. If the result is wrong, delegate a follow-up task. delegate_task results do not carry the + backend's own session id, so use resumeSessionId only when the user already knows one (for + example from the backend CLI's session history); otherwise start a fresh delegation with the + needed context in the task text. + +## Backend guidance + +- claude: general implementation and cross-model review of Codex output. +- codex: implementation and targeted edits. +- kimi: independent implementation pass or comparison run. +- zcode: marked experimental; verify the command template in backends.json first. +- dsh: marked experimental; verify the command template in backends.json first. + +## Safety rules + +- Never include credentials, tokens, private endpoints, or personal data in the task text. +- Keep allowDirty=false (the default) unless the user explicitly accepts running on a dirty tree. +- Default templates let the worker edit workspace files autonomously (for example claude runs + with --permission-mode acceptEdits); treat every returned diff as untrusted until reviewed. +- Review every change the worker produced before reporting completion. New files the worker + created are listed under changed files even though they do not appear in git diff --stat. +- Run parallel comparison workers in separate clean clones at the same starting commit. Linked + worktrees share refs and intentionally serialize; a second run in one checkout also inherits the + first run's edits and is not independent. +- Production delegation and workspace inspection require Windows Job Object containment. Linux, + macOS, and BSD return an unsupported result before backend configuration, workspace access, Git + resolution, or executable probing; do not bypass this lifecycle safety gate. +- Timeouts: the default is 20 minutes; adjust timeoutMs for very large tasks. The deadline includes + lock acquisition, preflight Git checks, the worker, and post-run snapshots. A + timed-out worker has its complete process tree terminated before the lock is released; safe + termination may use the additional kill grace period. +- Cancellation: cancelling an in-flight delegate_task call terminates the complete worker process + tree and the result reports cancelled=true. If tree termination cannot be confirmed, the bridge + writes a shared quarantine marker that blocks every bridge process. After checking for leftover + processes, an operator must deliberately rename the reported quarantinePath with the + `.recovery-approved` suffix. Mere marker absence never authorizes recovery. The workspace may + still contain edits made before cancellation. After cleanup completes, call `workspace_status` + in a fresh request and review that snapshot; the cancelled delegation's `git` field may be null. + If quarantine blocks that status request, complete the documented recovery procedure first. +- Snapshot reliability: a worker's changes to Git refs are compared as well as final HEAD, and a + truncated Git capture fails closed. If outputTruncated/stderrTruncated is true, treat the returned + backend tail as partial. +- Cancelling workspace_status while it is queued or snapshotting returns promptly with + cancelled=true; it does not run a delayed status snapshot after the active delegation finishes. +- workspace_status does not edit target refs or worktree files, but cross-process serialization + writes an owner blob and coordination ref in the private bare repository at + `/cli-agent-bridge-lock-store.git`. That store must be writable and is separate + so mirrored pushes cannot publish lock metadata. +- Only stale idle locks with a positively dead same-host owner are reclaimed automatically. A stale + starting/running ref fails closed because escaped descendants cannot be reconstructed after a + bridge crash; inspect the process tree before deliberately clearing its lock-store ref. A lease + moved to the quarantined state is reclaimable once the operator performs that explicit approval + rename. + +## Notes + +- This Skill only instructs the agent. The MCP server shipped with this Plugin launches the CLIs. +- The Plugin stores no credentials and makes no network calls of its own. diff --git a/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs new file mode 100644 index 0000000..38fb82d --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs @@ -0,0 +1,252 @@ +// Self-contained tests for the cli-agent-bridge stdio MCP server. +// Run with: node --test test/server.test.mjs +// No network access is required: the delegation test uses a fake slow backend. +// +// Every test drives its server through withServer(), which always stops the +// child process - a leaked server holds stdin/stdout pipes and would keep the +// node --test runner from ever exiting when an assertion fails. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn, execSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, mkdirSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const server = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "server.mjs"); + +function startServer(extraEnv = {}) { + const child = spawn(process.execPath, [server], { + env: { + ...process.env, + NODE_ENV: "test", + CLI_AGENT_BRIDGE_TEST_PROCESS_TREE_MODE: "1", + ...extraEnv, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + let buf = ""; + const pending = new Map(); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (d) => { + buf += d; + let i; + while ((i = buf.indexOf("\n")) !== -1) { + const line = buf.slice(0, i); + buf = buf.slice(i + 1); + if (!line.trim()) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + if (msg.id !== undefined && pending.has(msg.id)) { + pending.get(msg.id)(msg); + pending.delete(msg.id); + } + } + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (d) => process.stderr.write("[server] " + d)); + const rpc = (id, method, params) => new Promise((resolve) => { + pending.set(id, resolve); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + }); + const notify = (method, params) => { + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n"); + }; + const stop = () => { try { child.kill("SIGKILL"); } catch { /* already gone */ } }; + return { child, rpc, notify, stop }; +} + +async function withServer(extraEnv, fn) { + const s = startServer(extraEnv); + try { + await s.rpc(1, "initialize", {}); + return await fn(s); + } finally { + s.stop(); // always released, even when an assertion throws + } +} + +function makeRepo() { + const dir = mkdtempSync(path.join(tmpdir(), "bridge-test-")); + execSync("git init -q", { cwd: dir }); + execSync("git config user.name test", { cwd: dir }); + execSync("git config user.email test@example.com", { cwd: dir }); + writeFileSync(path.join(dir, "hello.txt"), "hello"); + execSync("git add hello.txt && git commit -q -m init", { cwd: dir }); + return dir; +} + +function writeBackends(name, backends) { + const cfg = path.join(tmpdir(), name); + writeFileSync(cfg, JSON.stringify({ backends })); + return { CLI_AGENT_BRIDGE_BACKENDS: cfg }; +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +test("initialize negotiates only the supported protocol version", async () => { + const s = startServer(); + const init = await s.rpc(1, "initialize", { protocolVersion: "2024-11-05" }); + assert.equal(init.result.protocolVersion, "2025-06-18"); + assert.equal(init.result.serverInfo.name, "cli-agent-bridge"); + s.stop(); +}); + +test("tools/list exposes the three bridge tools", async () => { + await withServer({}, async (s) => { + const list = await s.rpc(2, "tools/list"); + const names = list.result.tools.map((t) => t.name).sort(); + assert.deepEqual(names, ["delegate_task", "list_backends", "workspace_status"]); + const delegate = list.result.tools.find((t) => t.name === "delegate_task"); + assert.equal(delegate.annotations.destructiveHint, true); + const status = list.result.tools.find((t) => t.name === "workspace_status"); + assert.equal(status.annotations.readOnlyHint, false); + assert.equal(status.annotations.idempotentHint, false); + }); +}); + +test("workspace_status reports changed files including untracked ones", async () => { + await withServer({}, async (s) => { + const repo = makeRepo(); + writeFileSync(path.join(repo, "new-file.txt"), "new"); + const res = await s.rpc(2, "tools/call", { name: "workspace_status", arguments: { workspacePath: repo } }); + assert.equal(res.result.structuredContent.ok, true); + assert.ok(res.result.structuredContent.git.changedFiles.includes("new-file.txt")); + }); +}); + +test("delegate_task refuses a dirty tree without allowDirty and sets isError", async () => { + const env = writeBackends("bridge-dirty-backends.json", { + fake: { command: process.execPath, buildArgs: ["-e", "", ""], experimental: true }, + }); + await withServer(env, async (s) => { + const repo = makeRepo(); + writeFileSync(path.join(repo, "dirty.txt"), "dirty"); + const res = await s.rpc(2, "tools/call", { name: "delegate_task", arguments: { backend: "fake", task: "x", workspacePath: repo } }); + assert.equal(res.result.structuredContent.ok, false); + assert.equal(res.result.isError, true); + assert.match(res.result.structuredContent.error, /dirty/); + }); +}); + +test("delegate_task rejects unknown backends", async () => { + await withServer({}, async (s) => { + const repo = makeRepo(); + const res = await s.rpc(2, "tools/call", { name: "delegate_task", arguments: { backend: "nope", task: "x", workspacePath: repo } }); + assert.match(res.error.message, /unknown backend/); + }); +}); + +test("notifications/cancelled terminates an in-flight worker", async () => { + const env = writeBackends("bridge-slow-backends.json", { + slow: { command: "node", buildArgs: ["-e", "setTimeout(()=>{},120000)", ""], experimental: true }, + }); + await withServer(env, async (s) => { + const repo = makeRepo(); + const start = Date.now(); + const promise = s.rpc(7, "tools/call", { name: "delegate_task", arguments: { backend: "slow", task: "do nothing", workspacePath: repo, timeoutMs: 60_000 } }); + setTimeout(() => s.notify("notifications/cancelled", { requestId: 7 }), 500); + const res = await promise; + const elapsed = Date.now() - start; + assert.equal(res.result.structuredContent.cancelled, true); + assert.equal(res.result.isError, true); + assert.ok(elapsed < 10_000, "cancellation must settle well before the 60s backend timeout"); + }); +}); + +test("delegate_task returns before and after snapshots and committed deltas", async () => { + const env = writeBackends("bridge-fake-backends.json", { + fake: { command: "node", buildArgs: ["-e", "require('node:fs').appendFileSync('marker.txt','ok')", ""], experimental: true }, + }); + await withServer(env, async (s) => { + const repo = makeRepo(); + const res = await s.rpc(2, "tools/call", { name: "delegate_task", arguments: { backend: "fake", task: "make a marker", workspacePath: repo } }); + const out = res.result.structuredContent; + assert.equal(out.ok, true); + assert.equal(out.exitCode, 0); + assert.ok(out.gitBefore && out.git, "before and after snapshots must both be present"); + assert.ok(out.git.changedFiles.includes("marker.txt")); + assert.equal(out.commits, null, "internal lease heartbeats must not appear as committed changes"); + assert.ok(Object.keys(out.gitBefore.refs).every((ref) => + !ref.startsWith("refs/cli-agent-bridge/workspace-locks/"))); + assert.ok(Object.keys(out.git.refs).every((ref) => + !ref.startsWith("refs/cli-agent-bridge/workspace-locks/"))); + }); +}); + +test("locks are keyed by the worktree root, so subdir paths serialize with the root", async () => { + // Each run appends "start", waits, appends "end" to an absolute-path log. + // If the root path and a subdirectory path shared no lock, the log would + // read start,start,end,end instead of strictly alternating. + const repo = makeRepo(); + const sub = path.join(repo, "nested", "deep"); + mkdirSync(sub, { recursive: true }); + const logFile = path.join(repo, "order.log").replace(/\\/g, "/"); + const script = `const fs=require('node:fs');` + + `fs.appendFileSync(${JSON.stringify(logFile)},'start\\n');` + + `setTimeout(()=>{fs.appendFileSync(${JSON.stringify(logFile)},'end\\n')},700);`; + const env = writeBackends("bridge-order-backends.json", { + orderer: { command: "node", buildArgs: ["-e", script, ""], experimental: true }, + }); + await withServer(env, async (s) => { + const first = s.rpc(2, "tools/call", { name: "delegate_task", arguments: { backend: "orderer", task: "x", workspacePath: repo, timeoutMs: 60_000 } }); + // Let the first request deterministically acquire the lock and start its + // worker before sending the second, which runs from a subdirectory. + await sleep(600); + // The queued follow-up must accept the tree the first run leaves dirty + // (order.log is untracked); allowDirty=false would refuse it. + const second = s.rpc(3, "tools/call", { name: "delegate_task", arguments: { backend: "orderer", task: "x", workspacePath: sub, allowDirty: true, timeoutMs: 60_000 } }); + const [r1, r2] = await Promise.all([first, second]); + assert.equal(r1.result.structuredContent.ok, true, JSON.stringify(r1.result?.structuredContent?.error ?? r1.error)); + assert.equal(r2.result.structuredContent.ok, true, JSON.stringify(r2.result?.structuredContent?.error ?? r2.error)); + const log = readFileSync(path.join(repo, "order.log"), "utf8").trim().split(/\r?\n/); + assert.deepEqual(log, ["start", "end", "start", "end"], "root and subdir delegations must serialize: " + log.join(",")); + }); +}); + +test("a delegation cancelled while queued for the lock never starts its worker", async () => { + const env = writeBackends("bridge-queue-cancel.json", { + slow: { command: "node", buildArgs: ["-e", "setTimeout(()=>{},4000)", ""], experimental: true }, + }); + await withServer(env, async (s) => { + const repo = makeRepo(); + const first = s.rpc(10, "tools/call", { name: "delegate_task", arguments: { backend: "slow", task: "hold the lock", workspacePath: repo, timeoutMs: 60_000 } }); + // Let the first request actually acquire the lock and start its worker + // before sending the second, so the second is deterministically queued. + await sleep(600); + const second = s.rpc(11, "tools/call", { name: "delegate_task", arguments: { backend: "slow", task: "queued", workspacePath: repo, timeoutMs: 60_000 } }); + await sleep(600); // second request is now queued behind the lock + s.notify("notifications/cancelled", { requestId: 11 }); + const [r1, r2] = await Promise.all([first, second]); + assert.equal(r1.result.structuredContent.ok, true); + assert.equal(r2.result.structuredContent.cancelled, true); + assert.match(r2.result.structuredContent.error, /cancelled.*worker.*started/iu); + assert.equal(r2.result.structuredContent.exitCode, null, "the cancelled worker must never have started"); + assert.equal(readFileSync(path.join(repo, "hello.txt"), "utf8"), "hello", "workspace untouched"); + }); +}); + +test("repositories with an unborn HEAD (no commits yet) are supported", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "bridge-unborn-")); + execSync("git init -q", { cwd: dir }); + execSync("git config user.name test && git config user.email test@example.com", { cwd: dir }); + const env = writeBackends("bridge-unborn-backends.json", { + fake: { command: "node", buildArgs: ["-e", "require('node:fs').writeFileSync('first.txt','ok')", ""], experimental: true }, + }); + await withServer(env, async (s) => { + const status = await s.rpc(2, "tools/call", { name: "workspace_status", arguments: { workspacePath: dir } }); + assert.equal(status.result.structuredContent.ok, true, JSON.stringify(status.error ?? "")); + const res = await s.rpc(3, "tools/call", { name: "delegate_task", arguments: { backend: "fake", task: "first file", workspacePath: dir, timeoutMs: 30_000 } }); + const out = res.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out.error)); + assert.equal(out.gitBefore.head, ""); + assert.ok(out.git.changedFiles.includes("first.txt")); + }); +}); + +test("codex templates delimit the prompt from CLI options with --", () => { + const backends = JSON.parse(readFileSync(path.join(path.dirname(server), "backends.json"), "utf8")).backends; + assert.deepEqual(backends.codex.buildArgs, ["exec", "--", ""]); + assert.deepEqual(backends.codex.resumeArgs, ["exec", "resume", "", "--", ""]); +}); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs new file mode 100644 index 0000000..b9d6f2b --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs @@ -0,0 +1,237 @@ +#!/usr/bin/env node + +import { appendFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { execFileSync, spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ownPath = fileURLToPath(import.meta.url); +const spec = JSON.parse(process.argv[2] ?? "{}"); + +if (spec.ignoreSigterm === true && process.platform !== "win32") { + process.on("SIGTERM", () => {}); +} + +function event(name) { + if (!spec.eventFile) return; + appendFileSync(spec.eventFile, JSON.stringify({ event: name, name: spec.name ?? "", pid: process.pid }) + "\n"); +} + +async function delay(milliseconds) { + await new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +if (spec.branchRoundTrip) { + event("start"); + const original = execFileSync("git", ["branch", "--show-current"], { encoding: "utf8" }).trim(); + execFileSync("git", ["checkout", "-b", spec.branchName ?? "worker-branch"]); + writeFileSync(path.resolve(process.cwd(), spec.writeFile ?? "branch-work.txt"), spec.contents ?? "branch commit\n"); + execFileSync("git", ["add", spec.writeFile ?? "branch-work.txt"]); + execFileSync("git", ["commit", "-m", spec.commitMessage ?? "worker branch commit"]); + execFileSync("git", ["checkout", original]); + event("end"); +} else if (spec.checkoutExisting) { + // Merely check out a pre-existing divergent branch; no commits are created. + event("start"); + execFileSync("git", ["checkout", spec.branchName]); + event("end"); +} else if (spec.commitCurrent) { + // Commit on the currently checked-out branch: HEAD and its branch ref move together. + event("start"); + if (spec.environmentFile) { + writeFileSync(spec.environmentFile, JSON.stringify({ + GIT_DIR: process.env.GIT_DIR ?? null, + GIT_WORK_TREE: process.env.GIT_WORK_TREE ?? null, + GIT_INDEX_FILE: process.env.GIT_INDEX_FILE ?? null, + GIT_CEILING_DIRECTORIES: process.env.GIT_CEILING_DIRECTORIES ?? null, + GIT_NO_REPLACE_OBJECTS: process.env.GIT_NO_REPLACE_OBJECTS ?? null, + GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? null, + GH_TOKEN: process.env.GH_TOKEN ?? null, + })); + } + writeFileSync(path.resolve(process.cwd(), spec.writeFile ?? "current.txt"), spec.contents ?? "current\n"); + execFileSync("git", ["add", spec.writeFile ?? "current.txt"]); + execFileSync("git", ["commit", "-m", spec.commitMessage ?? "worker commit on current branch"]); + if (spec.refName) { + const oid = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + execFileSync("git", ["update-ref", spec.refName, oid]); + } + event("end"); +} else if (spec.fetchAndCommit) { + // Simulate a fetch that adds external history, followed by one local worker + // commit based on the fetched tip. + event("start"); + const original = execFileSync("git", ["branch", "--show-current"], { encoding: "utf8" }).trim(); + execFileSync("git", ["checkout", "-b", "fixture-upstream"]); + writeFileSync(path.resolve(process.cwd(), "upstream.txt"), "external upstream history\n"); + execFileSync("git", ["add", "upstream.txt"]); + execFileSync("git", ["commit", "-m", "fetched upstream commit"]); + const upstreamOid = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + execFileSync("git", ["checkout", original]); + execFileSync("git", ["branch", "-D", "fixture-upstream"]); + if (spec.fetchTagOnly) { + execFileSync("git", ["update-ref", "refs/tags/fetched-tag", upstreamOid]); + } else if (spec.fetchPrefetch) { + execFileSync("git", ["update-ref", "refs/prefetch/remotes/origin/main", upstreamOid]); + } else { + execFileSync("git", ["update-ref", "refs/remotes/origin/main", upstreamOid]); + } + execFileSync("git", ["checkout", "-b", spec.branchName ?? "fetched-work", upstreamOid]); + writeFileSync(path.resolve(process.cwd(), spec.writeFile ?? "worker-after-fetch.txt"), "worker\n"); + execFileSync("git", ["add", spec.writeFile ?? "worker-after-fetch.txt"]); + execFileSync("git", ["commit", "-m", spec.commitMessage ?? "worker commit after fetch"]); + execFileSync("git", ["checkout", original]); + event("end"); +} else if (spec.fetchIntoLocalRef) { + // Exercise fetch's arbitrary destination refspec. FETCH_HEAD, rather than + // the refs/heads destination namespace, is the provenance signal. + event("start"); + const original = execFileSync("git", ["branch", "--show-current"], { encoding: "utf8" }).trim(); + const importedRef = spec.importedRef ?? "refs/heads/imported-upstream"; + execFileSync("git", [ + "fetch", spec.remotePath, "refs/heads/topic:" + importedRef, + ]); + execFileSync("git", ["checkout", "-b", spec.branchName ?? "local-fetch-work", importedRef]); + writeFileSync(path.resolve(process.cwd(), spec.writeFile ?? "worker-after-local-fetch.txt"), "worker\n"); + execFileSync("git", ["add", spec.writeFile ?? "worker-after-local-fetch.txt"]); + execFileSync("git", ["commit", "-m", spec.commitMessage ?? "worker commit after local-ref fetch"]); + execFileSync("git", ["checkout", original]); + event("end"); +} else if (spec.pullNoRebase) { + event("start"); + execFileSync("git", ["pull", "--no-rebase", "--no-edit", spec.remotePath, "topic"]); + event("end"); +} else if (spec.fetchOtherRepositoryThenCommit) { + event("start"); + execFileSync("git", ["-C", spec.otherRepository, "fetch", spec.remotePath, "refs/heads/topic"]); + writeFileSync(path.resolve(process.cwd(), spec.writeFile), "target work\n"); + execFileSync("git", ["add", spec.writeFile]); + execFileSync("git", ["commit", "-m", spec.commitMessage]); + event("end"); +} else if (spec.fetchOnlyThenWrite) { + event("start"); + execFileSync("git", ["fetch", spec.remotePath, "refs/heads/topic"]); + writeFileSync(path.resolve(process.cwd(), spec.writeFile), "worktree-only change\n"); + event("end"); +} else if (spec.writeConcurrencyHistory) { + event("start"); + const record = JSON.stringify({ + version: 1, + acquiredAt: spec.acquiredAt, + endedAt: spec.endedAt, + hostIdentity: "fixture:clock-skewed-host", + }) + "\n"; + const oid = execFileSync( + "git", ["hash-object", "-w", "--stdin"], + { cwd: spec.historyStore, input: record, encoding: "utf8" }, + ).trim(); + execFileSync("git", ["update-ref", spec.historyRef, oid], { cwd: spec.historyStore }); + if (spec.writeFile) { + writeFileSync(path.resolve(process.cwd(), spec.writeFile), "clock-independent overlap\n"); + } + event("end"); +} else if (spec.corruptTraceThenWrite) { + event("start"); + appendFileSync(process.env.GIT_TRACE2_EVENT, "{malformed-trace2-event\n"); + writeFileSync(path.resolve(process.cwd(), spec.writeFile), "trace fallback worktree change\n"); + event("end"); +} else if (spec.replaceTraceThenWrite) { + event("start"); + const tracePath = process.env.GIT_TRACE2_EVENT; + if (spec.traceRootFile) writeFileSync(spec.traceRootFile, path.dirname(tracePath)); + unlinkSync(tracePath); + writeFileSync(tracePath, ""); + writeFileSync(path.resolve(process.cwd(), spec.writeFile), "replacement trace worktree change\n"); + event("end"); +} else if (spec.writeBeforeDelay) { + event("start"); + writeFileSync(path.resolve(process.cwd(), spec.writeFile), spec.contents ?? "written before cancellation\n"); + event("written"); + await delay(spec.delayMs ?? 60_000); + event("end"); +} else if (spec.mirrorPush) { + event("start"); + execFileSync("git", ["push", "--mirror", spec.remotePath]); + event("end"); +} else if (spec.newBranchFromExisting) { + // Fork a new branch from a pre-existing divergent branch, commit, and return. + event("start"); + const original = execFileSync("git", ["branch", "--show-current"], { encoding: "utf8" }).trim(); + execFileSync("git", ["checkout", spec.fromBranch]); + execFileSync("git", ["checkout", "-b", spec.branchName ?? "forked-work"]); + writeFileSync(path.resolve(process.cwd(), spec.writeFile ?? "fork.txt"), spec.contents ?? "fork\n"); + execFileSync("git", ["add", spec.writeFile ?? "fork.txt"]); + execFileSync("git", ["commit", "-m", spec.commitMessage ?? "worker fork commit"]); + execFileSync("git", ["checkout", original]); + event("end"); +} else if (spec.blobTag) { + // Create a legal ref that points at a blob, not a commit. + event("start"); + const oid = execFileSync( + "git", + ["hash-object", "-w", "--stdin"], + { input: "blob payload\n", encoding: "utf8" }, + ).trim(); + execFileSync("git", ["update-ref", spec.refName ?? "refs/tags/blobtag", oid]); + event("end"); +} else if (spec.moveBlobRefToCommit) { + // Move a pre-existing non-commit ref to a commit created during this run, + // without leaving another changed branch ref that could mask attribution. + event("start"); + const original = execFileSync("git", ["branch", "--show-current"], { encoding: "utf8" }).trim(); + const temporaryBranch = spec.branchName ?? "temporary-ref-commit"; + execFileSync("git", ["checkout", "-b", temporaryBranch]); + writeFileSync(path.resolve(process.cwd(), spec.writeFile ?? "ref-commit.txt"), "ref commit\n"); + execFileSync("git", ["add", spec.writeFile ?? "ref-commit.txt"]); + execFileSync("git", ["commit", "-m", spec.commitMessage ?? "commit behind moved ref"]); + const oid = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + execFileSync("git", ["checkout", original]); + execFileSync("git", ["branch", "-D", temporaryBranch]); + execFileSync("git", ["update-ref", spec.refName, oid]); + event("end"); +} else if (spec.forceRefFromExisting) { + // Force-move an existing ref onto a different pre-existing lineage, then + // add exactly one worker commit without leaving a temporary branch behind. + event("start"); + const original = execFileSync("git", ["branch", "--show-current"], { encoding: "utf8" }).trim(); + const temporaryBranch = spec.temporaryBranch ?? "temporary-force-ref"; + execFileSync("git", ["checkout", spec.fromBranch]); + execFileSync("git", ["checkout", "-b", temporaryBranch]); + writeFileSync(path.resolve(process.cwd(), spec.writeFile ?? "forced-ref.txt"), "worker\n"); + execFileSync("git", ["add", spec.writeFile ?? "forced-ref.txt"]); + execFileSync("git", ["commit", "-m", spec.commitMessage ?? "worker commit on forced ref"]); + const oid = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + execFileSync("git", ["checkout", original]); + execFileSync("git", ["branch", "-D", temporaryBranch]); + execFileSync("git", ["update-ref", spec.refName, oid]); + event("end"); +} else if (spec.mode === "descendant") { + event("descendant-start"); + await delay(spec.delayMs ?? 1_000); + if (spec.writeFile) writeFileSync(path.resolve(process.cwd(), spec.writeFile), spec.contents ?? "descendant survived\n"); + event("descendant-end"); +} else if (spec.spawnDescendant) { + event("parent-start"); + const descendant = spawn(process.execPath, [ownPath, JSON.stringify({ + mode: "descendant", + name: spec.name, + eventFile: spec.eventFile, + delayMs: spec.descendantDelayMs, + writeFile: spec.descendantWriteFile, + contents: spec.contents, + })], { + cwd: process.cwd(), + detached: spec.detachedDescendant === true, + windowsHide: true, + stdio: spec.detachedDescendant === true ? "ignore" : "inherit", + }); + if (spec.detachedDescendant === true) descendant.unref(); + await delay(spec.parentDelayMs ?? 120_000); +} else if (spec.stdoutChars) { + process.stdout.write("x".repeat(spec.stdoutChars)); +} else { + event("start"); + await delay(spec.delayMs ?? 0); + if (spec.writeFile) writeFileSync(path.resolve(process.cwd(), spec.writeFile), spec.contents ?? spec.name ?? "done"); + event("end"); +} diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs new file mode 100644 index 0000000..7d5e040 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs @@ -0,0 +1,1060 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + isProcessTreeAlive, + initializeProcessTree, + linuxProcessGroupHasLiveMembers, + parsePosixProcessLine, + posixProcessSnapshot, + refreshProcessTree, + signalProcessTree, + waitForProcessTreeExit, + windowsProcessTreePids, +} from "../process-tree.mjs"; + +test("BSD ps snapshots retain process start identity for PID-reuse checks", () => { + assert.deepEqual( + parsePosixProcessLine(" 432 1 432 S Sun Aug 16 12:34:56 2026"), + { + pid: 432, + parentPid: 1, + processGroupId: 432, + state: "S", + startIdentity: "Sun Aug 16 12:34:56 2026", + }, + ); +}); + +async function writeProcStat(root, pid, { + state, group, parent = 1, startIdentity = pid, command = "worker", +}) { + const directory = path.join(root, String(pid)); + await mkdir(directory, { recursive: true }); + const fields = [state, String(parent), String(group), String(group)]; + while (fields.length < 20) fields.push("0"); + fields[19] = String(startIdentity); + await writeFile(path.join(directory, "stat"), `${pid} (${command}) ${fields.join(" ")}\n`); +} + +async function writeTaskChildren(root, pid, children) { + const taskDirectory = path.join(root, String(pid), "task", String(pid)); + await mkdir(taskDirectory, { recursive: true }); + await writeFile(path.join(taskDirectory, "children"), children.join(" ") + "\n"); +} + +async function writeRunMarker(root, pid, marker) { + await writeFile( + path.join(root, String(pid), "environ"), + `PATH=/fixture\0CLI_AGENT_BRIDGE_RUN_ID=${marker}\0`, + ); +} + +test("Linux ancestry refresh follows task children without scanning all of procfs", async (context) => { + const procRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-bridge-proc-")); + context.after(() => rm(procRoot, { recursive: true, force: true })); + await writeProcStat(procRoot, 601, { state: "S", group: 601, startIdentity: 10 }); + await writeProcStat(procRoot, 602, { + state: "S", group: 602, parent: 601, startIdentity: 11, + }); + await writeTaskChildren(procRoot, 601, [602]); + await writeTaskChildren(procRoot, 602, []); + const fsOps = { + readdir: async (target, options) => { + assert.notEqual(target, procRoot, "targeted refresh must not enumerate the proc root"); + return await readdir(target, options); + }, + readFile, + }; + const treeState = { knownPids: new Set([601]), knownStarts: new Map() }; + const snapshot = await refreshProcessTree({ pid: 601 }, treeState, { + platform: "linux", procRoot, fsOps, allowRootIdentityCapture: true, + }); + assert.deepEqual(new Set(snapshot.map((item) => item.pid)), new Set([601, 602])); + assert.equal(treeState.knownStarts.get(602), "11"); +}); + +test("Linux falls back safely when the live main task has no children file", async () => { + let childrenReads = 0; + const directory = (name) => ({ name: String(name), isDirectory: () => true }); + const fsOps = { + readdir: async (target) => { + if (target === "/fixture-proc/100/task") return [directory(100)]; + if (target === "/fixture-proc") return [directory(100), directory(200)]; + return []; + }, + readFile: async (target) => { + if (target.endsWith("/100/task/100/children")) { + childrenReads += 1; + throw missingProcessError("ENOENT"); + } + if (target.endsWith("/100/stat")) { + return procStatLine(100, { parent: 1, group: 100, startIdentity: 10 }); + } + if (target.endsWith("/200/stat")) { + return procStatLine(200, { parent: 100, group: 200, startIdentity: 20 }); + } + throw missingProcessError(); + }, + }; + const child = { pid: 100, exitCode: null, signalCode: null }; + const treeState = { knownPids: new Set([100]), knownStarts: new Map() }; + await initializeProcessTree(child, treeState, { + platform: "linux", procRoot: "/fixture-proc", fsOps, + }); + assert.equal(treeState.linuxTaskChildrenUnavailable, true); + assert.equal(treeState.knownStarts.get(100), "10"); + assert.equal(treeState.knownStarts.get(200), "20"); + assert.equal(treeState.processIdentityUncertain, undefined); + await refreshProcessTree(child, treeState, { + platform: "linux", procRoot: "/fixture-proc", fsOps, + }); + assert.equal(childrenReads, 1, "capability detection is cached after the verified fallback"); +}); + +test("Linux children-file fallback recovers a reparented marked descendant", async () => { + const directory = (name) => ({ name: String(name), isDirectory: () => true }); + const fsOps = { + readdir: async (target) => target === "/fixture-proc" ? [directory(300)] : [], + readFile: async (target) => { + if (target.endsWith("/100/stat")) throw missingProcessError(); + if (target.endsWith("/300/stat")) { + return procStatLine(300, { parent: 1, group: 300, startIdentity: 30 }); + } + if (target.endsWith("/300/environ")) { + return Buffer.from("PATH=/fixture\0CLI_AGENT_BRIDGE_RUN_ID=fallback-run\0"); + } + throw missingProcessError(); + }, + }; + const treeState = { + knownPids: new Set([100]), + knownStarts: new Map([[100, "10"]]), + linuxTaskChildrenUnavailable: true, + runMarker: "fallback-run", + }; + const snapshot = await refreshProcessTree({ pid: 100 }, treeState, { + platform: "linux", procRoot: "/fixture-proc", fsOps, + }); + assert.deepEqual(snapshot.map((item) => item.pid), [300]); + assert.equal(treeState.knownStarts.get(300), "30"); + assert.equal(treeState.processIdentityUncertain, undefined); +}); + +test("Linux children-file fallback rejects an unavailable full snapshot", async () => { + const directory = (name) => ({ name: String(name), isDirectory: () => true }); + const fsOps = { + readdir: async (target) => { + if (target === "/fixture-proc/100/task") return [directory(100)]; + if (target === "/fixture-proc") { + throw Object.assign(new Error("procfs denied"), { code: "EACCES" }); + } + return []; + }, + readFile: async (target) => { + if (target.endsWith("/100/task/100/children")) throw missingProcessError("ENOENT"); + if (target.endsWith("/100/stat")) { + return procStatLine(100, { parent: 1, group: 100, startIdentity: 10 }); + } + throw missingProcessError(); + }, + }; + const treeState = { knownPids: new Set([100]), knownStarts: new Map() }; + await assert.rejects(initializeProcessTree( + { pid: 100, exitCode: null, signalCode: null }, treeState, + { platform: "linux", procRoot: "/fixture-proc", fsOps }, + ), /identity was not captured/iu); + assert.equal(treeState.processIdentityUncertain, true); + assert.equal(treeState.knownStarts.size, 0); +}); + +test("Linux refresh recovers a marked detached child after its parent exits", async (context) => { + const procRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-bridge-proc-")); + context.after(() => rm(procRoot, { recursive: true, force: true })); + await writeProcStat(procRoot, 702, { + state: "S", group: 702, parent: 1, startIdentity: 22, + }); + await writeTaskChildren(procRoot, 702, []); + await writeRunMarker(procRoot, 702, "fixture-run"); + const treeState = { + knownPids: new Set([701]), + knownStarts: new Map(), + runMarker: "fixture-run", + }; + const snapshot = await refreshProcessTree({ pid: 701 }, treeState, { + platform: "linux", procRoot, fsOps: { readdir, readFile }, + }); + assert.deepEqual(snapshot.map((item) => item.pid), [702]); + assert.ok(treeState.knownPids.has(702), + "the inherited run marker preserves containment after orphan reparenting"); +}); + +function procStatLine(pid, { state = "S", parent = 1, group = pid, startIdentity }) { + const fields = [state, String(parent), String(group), String(group)]; + while (fields.length < 20) fields.push("0"); + fields[19] = String(startIdentity); + return `${pid} (worker) ${fields.join(" ")}\n`; +} + +function missingProcessError(code = "ENOENT") { + return Object.assign(new Error("process exited"), { code }); +} + +function markerReuseFixture(startIdentities) { + let statReads = 0; + return { + fsOps: { + readdir: async (target) => target === "/fixture-proc" + ? [{ name: "702", isDirectory: () => true }] + : [], + readFile: async (target) => { + if (target.endsWith("/701/stat")) throw missingProcessError(); + if (target.endsWith("/702/environ")) { + return Buffer.from("CLI_AGENT_BRIDGE_RUN_ID=fixture-run\0"); + } + if (target.endsWith("/702/stat")) { + const identity = startIdentities[Math.min(statReads, startIdentities.length - 1)]; + statReads += 1; + return procStatLine(702, { startIdentity: identity }); + } + throw missingProcessError(); + }, + }, + treeState: { + knownPids: new Set([701]), knownStarts: new Map(), runMarker: "fixture-run", + }, + }; +} + +test("Linux marker recovery rejects a PID reused while its environment is read", async () => { + const { fsOps, treeState } = markerReuseFixture([22, 23]); + await assert.rejects(refreshProcessTree({ pid: 701 }, treeState, { + platform: "linux", procRoot: "/fixture-proc", fsOps, + }), /run-marker process identity changed during inspection/iu); + assert.ok(!treeState.knownPids.has(702)); + assert.equal(treeState.processIdentityUncertain, true); +}); + +test("Linux marker recovery rechecks identity before enrolling a marked PID", async () => { + const { fsOps, treeState } = markerReuseFixture([22, 22, 23]); + await assert.rejects(refreshProcessTree({ pid: 701 }, treeState, { + platform: "linux", procRoot: "/fixture-proc", fsOps, + }), /run-marker process identity changed between observations/iu); + assert.ok(!treeState.knownPids.has(702)); + assert.equal(treeState.processIdentityUncertain, true); +}); + +test("Linux marker recovery never rebinds an already tracked PID", async () => { + const { fsOps, treeState } = markerReuseFixture([23, 23]); + treeState.knownPids.add(702); + treeState.knownStarts.set(702, "22"); + const snapshot = await refreshProcessTree({ pid: 701 }, treeState, { + platform: "linux", procRoot: "/fixture-proc", fsOps, + }); + assert.deepEqual(snapshot.map((item) => item.pid), []); + assert.equal(treeState.knownStarts.get(702), "22", + "a marker cannot bless a replacement process that reused a tracked PID"); + assert.equal(treeState.processIdentityUncertain, true, + "an identity conflict must keep later liveness checks fail-closed"); +}); + +test("Linux marker recovery still runs when the leader PID was reused", async (context) => { + const procRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-bridge-proc-")); + context.after(() => rm(procRoot, { recursive: true, force: true })); + await writeProcStat(procRoot, 701, { state: "S", group: 701, startIdentity: 99 }); + await writeTaskChildren(procRoot, 701, []); + await writeProcStat(procRoot, 702, { + state: "S", group: 702, parent: 1, startIdentity: 22, + }); + await writeTaskChildren(procRoot, 702, []); + await writeRunMarker(procRoot, 702, "fixture-run"); + const treeState = { + knownPids: new Set([701]), + knownStarts: new Map([[701, "10"]]), + runMarker: "fixture-run", + }; + const snapshot = await refreshProcessTree({ pid: 701 }, treeState, { + platform: "linux", procRoot, fsOps: { readdir, readFile }, + }); + assert.deepEqual(snapshot.map((item) => item.pid), [702]); + assert.equal(treeState.knownStarts.get(701), "10"); +}); + +test("Linux liveness observes briefly for a late-visible marked child", async (context) => { + const procRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-bridge-proc-")); + context.after(() => rm(procRoot, { recursive: true, force: true })); + await writeProcStat(procRoot, 802, { + state: "S", group: 802, parent: 1, startIdentity: 33, + }); + await writeTaskChildren(procRoot, 802, []); + await writeRunMarker(procRoot, 802, "late-run"); + let markerScans = 0; + const fsOps = { + readdir: async (target, options) => { + if (target === procRoot && ++markerScans < 3) return []; + return await readdir(target, options); + }, + readFile, + }; + const treeState = { + knownPids: new Set([801]), knownStarts: new Map(), runMarker: "late-run", + }; + const alive = await isProcessTreeAlive({ pid: 801 }, treeState, { + platform: "linux", procRoot, fsOps, + probeProcessGroup: () => { const error = new Error("gone"); error.code = "ESRCH"; throw error; }, + }); + assert.equal(alive, true); + assert.ok(markerScans >= 3, "the empty first scan must not release containment"); + assert.ok(treeState.knownPids.has(802)); +}); + +test("Linux liveness ignores zombie-only process groups", async (context) => { + const procRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-bridge-proc-")); + context.after(() => rm(procRoot, { recursive: true, force: true })); + await writeProcStat(procRoot, 101, { state: "Z", group: 77, command: "leader) name" }); + await writeProcStat(procRoot, 102, { state: "X", group: 77 }); + await writeProcStat(procRoot, 103, { state: "S", group: 88 }); + + assert.equal(await linuxProcessGroupHasLiveMembers(77, procRoot), false); +}); + +test("Linux liveness keeps a process group with any non-zombie member", async (context) => { + const procRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-bridge-proc-")); + context.after(() => rm(procRoot, { recursive: true, force: true })); + await writeProcStat(procRoot, 201, { state: "Z", group: 99 }); + await writeProcStat(procRoot, 202, { state: "D", group: 99 }); + + assert.equal(await linuxProcessGroupHasLiveMembers(99, procRoot), true); +}); + +test("Linux liveness is unknown when no proc record matches the process group", async (context) => { + const procRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-bridge-proc-")); + context.after(() => rm(procRoot, { recursive: true, force: true })); + await writeProcStat(procRoot, 250, { state: "S", group: 200 }); + + assert.equal(await linuxProcessGroupHasLiveMembers(201, procRoot), null); +}); + +test("Linux liveness is unknown when a proc stat record is ambiguous", async (context) => { + const procRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-bridge-proc-")); + context.after(() => rm(procRoot, { recursive: true, force: true })); + const directory = path.join(procRoot, "301"); + await mkdir(directory); + await writeFile(path.join(directory, "stat"), "not a valid proc stat record\n"); + + assert.equal(await linuxProcessGroupHasLiveMembers(101, procRoot), null); +}); + +test("Linux liveness is unknown when procfs is missing or restricted", async () => { + const missingRoot = path.join(os.tmpdir(), `missing-proc-${process.pid}-${Date.now()}`); + assert.equal(await linuxProcessGroupHasLiveMembers(401, missingRoot), null); + + const denied = Object.assign(new Error("access denied"), { code: "EACCES" }); + const fsOps = { + readdir: async () => [{ name: "402", isDirectory: () => true }], + readFile: async () => { throw denied; }, + }; + assert.equal(await linuxProcessGroupHasLiveMembers(401, "/fake-proc", fsOps), null); +}); + +test("zombie-only groups count as exited only for the final post-SIGKILL wait", async (context) => { + const procRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-bridge-proc-")); + context.after(() => rm(procRoot, { recursive: true, force: true })); + await writeProcStat(procRoot, 501, { state: "Z", group: 501 }); + const child = { pid: 501 }; + const treeState = { + knownPids: new Set([501]), knownStarts: new Map([[501, "501"]]), + }; + const probeProcessGroup = () => {}; + const common = { platform: "linux", procRoot, probeProcessGroup }; + + assert.equal(await isProcessTreeAlive(child, treeState, common), true); + assert.equal(await isProcessTreeAlive(child, treeState, { ...common, ignoreZombieOnly: true }), false); + assert.equal(await waitForProcessTreeExit(child, 0, treeState, common), false); + assert.equal(await waitForProcessTreeExit(child, 0, treeState, { ...common, ignoreZombieOnly: true }), true); +}); + +function snapshotOf(processes) { + const list = processes.map((item) => ({ startIdentity: "", ...item })); + list.incomplete = false; + return async () => list; +} + +test("the process group is signaled only while its leader identity is original", async () => { + const child = { pid: 9001 }; + const treeState = { knownPids: new Set([9001]), knownStarts: new Map() }; + const original = snapshotOf([ + { pid: 9001, parentPid: 1, processGroupId: 9001, state: "S", startIdentity: "start-a" }, + { pid: 9002, parentPid: 9001, processGroupId: 9001, state: "S", startIdentity: "start-b" }, + ]); + await refreshProcessTree(child, treeState, { + platform: "linux", posixProcessSnapshot: original, allowRootIdentityCapture: true, + }); + + const groupSignals = []; + const oneSignals = []; + await signalProcessTree(child, "SIGTERM", treeState, { + platform: "linux", + posixProcessSnapshot: original, + killGroup: (pgid) => { groupSignals.push(pgid); }, + killOne: (pid) => { oneSignals.push(pid); }, + }); + assert.deepEqual(groupSignals, [9001], "the original group is signaled"); + assert.ok(oneSignals.includes(9002), "tracked descendants are signaled individually"); + + // The leader exits; during the kill grace its PID is reused by an unrelated + // process that leads a new group. The saved PGID must never be signaled. + const reused = snapshotOf([ + { pid: 9002, parentPid: 1, processGroupId: 9001, state: "S", startIdentity: "start-b" }, + { pid: 9001, parentPid: 404, processGroupId: 9001, state: "S", startIdentity: "start-reused" }, + ]); + const groupSignalsAfterReuse = []; + await signalProcessTree(child, "SIGKILL", treeState, { + platform: "linux", + posixProcessSnapshot: reused, + killGroup: (pgid) => { groupSignalsAfterReuse.push(pgid); }, + killOne: () => {}, + }); + assert.deepEqual(groupSignalsAfterReuse, [], "a reused leader identity stops group signaling"); +}); + +test("a reused POSIX leader cannot contribute unrelated descendants", async () => { + const child = { pid: 9300 }; + const treeState = { + knownPids: new Set([9300]), + knownStarts: new Map([[9300, "original-leader"]]), + }; + const reused = snapshotOf([ + { pid: 9300, parentPid: 1, processGroupId: 9300, state: "S", startIdentity: "replacement-leader" }, + { pid: 9301, parentPid: 9300, processGroupId: 9300, state: "S", startIdentity: "unrelated-child" }, + ]); + await refreshProcessTree(child, treeState, { + platform: "linux", posixProcessSnapshot: reused, + }); + assert.equal(treeState.knownPids.has(9301), false, + "children of the replacement leader must not enter the tracked tree"); + const oneSignals = []; + await signalProcessTree(child, "SIGKILL", treeState, { + platform: "linux", posixProcessSnapshot: reused, + killGroup: () => { throw new Error("a reused group must not be signaled"); }, + killOne: (pid) => { oneSignals.push(pid); }, + }); + assert.deepEqual(oneSignals, []); +}); + +test("a post-launch refresh cannot first-bind a replacement leader", async () => { + const child = { pid: 9350 }; + const treeState = { knownPids: new Set([9350]), knownStarts: new Map() }; + const replacement = snapshotOf([ + { pid: 9350, parentPid: 1, processGroupId: 9350, state: "S", startIdentity: "replacement" }, + { pid: 9351, parentPid: 9350, processGroupId: 9350, state: "S", startIdentity: "unrelated" }, + ]); + await refreshProcessTree(child, treeState, { + platform: "linux", posixProcessSnapshot: replacement, + }); + assert.equal(treeState.knownStarts.has(9350), false); + assert.equal(treeState.knownPids.has(9351), false); + const groupSignals = []; + const oneSignals = []; + await signalProcessTree(child, "SIGTERM", treeState, { + platform: "linux", posixProcessSnapshot: replacement, + killGroup: (pid) => { groupSignals.push(pid); }, + killOne: (pid) => { oneSignals.push(pid); }, + }); + assert.deepEqual(groupSignals, []); + assert.deepEqual(oneSignals, []); +}); + +test("a leader with an unavailable current identity cannot seed ancestry", async () => { + const child = { pid: 9370 }; + const treeState = { + knownPids: new Set([9370]), knownStarts: new Map([[9370, "original"]]), + }; + const unknown = snapshotOf([ + { pid: 9370, parentPid: 1, processGroupId: 9370, state: "S", startIdentity: "" }, + { pid: 9371, parentPid: 9370, processGroupId: 9370, state: "S", startIdentity: "unrelated" }, + ]); + await refreshProcessTree(child, treeState, { + platform: "linux", posixProcessSnapshot: unknown, + }); + assert.equal(treeState.knownPids.has(9371), false); + const groupSignals = []; + await signalProcessTree(child, "SIGTERM", treeState, { + platform: "linux", posixProcessSnapshot: unknown, + killGroup: (pid) => { groupSignals.push(pid); }, killOne: () => {}, + }); + assert.deepEqual(groupSignals, []); +}); + +test("Linux ancestry rejects a PID reused after a stale children entry", async () => { + let rootStatReads = 0; + const fsOps = { + readdir: async (target) => { + if (target === "/fixture-proc/100/task") { + return [{ name: "100", isDirectory: () => true }]; + } + if (target === "/fixture-proc") return []; + return []; + }, + readFile: async (target) => { + if (target.endsWith("/100/stat")) { + rootStatReads += 1; + return procStatLine(100, { parent: 1, group: 100, startIdentity: 10 }); + } + if (target.endsWith("/100/task/100/children")) return "200\n"; + if (target.endsWith("/200/stat")) { + return procStatLine(200, { parent: 999, group: 200, startIdentity: 20 }); + } + throw missingProcessError(); + }, + }; + const treeState = { + knownPids: new Set([100]), knownStarts: new Map([[100, "10"]]), + runMarker: "fixture-run", + }; + await refreshProcessTree({ pid: 100 }, treeState, { + platform: "linux", procRoot: "/fixture-proc", fsOps, + }); + assert.equal(rootStatReads, 2); + assert.equal(treeState.knownPids.has(200), false); + assert.equal(treeState.processIdentityUncertain, true); +}); + +test("Linux ancestry rechecks the parent before accepting its children", async () => { + let rootStatReads = 0; + const fsOps = { + readdir: async (target) => { + if (target === "/fixture-proc/100/task") { + return [{ name: "100", isDirectory: () => true }]; + } + if (target === "/fixture-proc") return []; + return []; + }, + readFile: async (target) => { + if (target.endsWith("/100/stat")) { + rootStatReads += 1; + return procStatLine(100, { + parent: 1, group: 100, startIdentity: rootStatReads === 1 ? 10 : 90, + }); + } + if (target.endsWith("/100/task/100/children")) return "200\n"; + throw missingProcessError(); + }, + }; + const treeState = { + knownPids: new Set([100]), knownStarts: new Map([[100, "10"]]), + runMarker: "fixture-run", + }; + await refreshProcessTree({ pid: 100 }, treeState, { + platform: "linux", procRoot: "/fixture-proc", fsOps, + }); + assert.equal(treeState.knownPids.has(200), false); + assert.equal(treeState.processIdentityUncertain, true); +}); + +test("Linux ancestry accepts a childless root exit only after stable empty marker scans", async () => { + let rootStatReads = 0; + let markerScans = 0; + const fsOps = { + readdir: async (target) => { + if (target === "/fixture-proc/100/task") { + return [{ name: "100", isDirectory: () => true }]; + } + if (target === "/fixture-proc") { + markerScans += 1; + return []; + } + return []; + }, + readFile: async (target) => { + if (target.endsWith("/100/stat")) { + rootStatReads += 1; + if (rootStatReads === 1) { + return procStatLine(100, { parent: 1, group: 100, startIdentity: 10 }); + } + throw missingProcessError("ESRCH"); + } + if (target.endsWith("/100/task/100/children")) return "\n"; + throw missingProcessError(); + }, + }; + const treeState = { + knownPids: new Set([100]), knownStarts: new Map([[100, "10"]]), + runMarker: "fixture-run", + }; + await refreshProcessTree({ pid: 100 }, treeState, { + platform: "linux", procRoot: "/fixture-proc", fsOps, + }); + assert.equal(markerScans, 2, + "a disappearing parent requires two identical full marker observations"); + assert.equal(treeState.processIdentityUncertain, undefined, + "a stable empty marker observation proves a childless exit cleanly"); +}); + +test("Linux ancestry tolerates runner and worker exits between stable scans", async () => { + let rootStatReads = 0; + let markerScans = 0; + const fsOps = { + readdir: async (target) => { + if (target === "/fixture-proc/100/task") { + return [{ name: "100", isDirectory: () => true }]; + } + if (target === "/fixture-proc") { + markerScans += 1; + if (markerScans === 1) { + return [200, 201].map((pid) => ({ name: String(pid), isDirectory: () => true })); + } + return markerScans === 2 + ? [{ name: "200", isDirectory: () => true }] + : []; + } + return []; + }, + readFile: async (target) => { + if (target.endsWith("/100/stat")) { + rootStatReads += 1; + if (rootStatReads === 1) { + return procStatLine(100, { parent: 1, group: 100, startIdentity: 10 }); + } + throw missingProcessError(); + } + if (target.endsWith("/100/task/100/children")) return "\n"; + if (target.endsWith("/200/stat")) { + return procStatLine(200, { parent: 1, group: 200, startIdentity: 20 }); + } + if (target.endsWith("/201/stat")) { + return procStatLine(201, { parent: 200, group: 200, startIdentity: 21 }); + } + if (target.endsWith("/200/environ") || target.endsWith("/201/environ")) { + return Buffer.from("CLI_AGENT_BRIDGE_RUN_ID=fixture-run\0"); + } + throw missingProcessError(); + }, + }; + const treeState = { + knownPids: new Set([100]), knownStarts: new Map([[100, "10"]]), + runMarker: "fixture-run", + }; + await refreshProcessTree({ pid: 100 }, treeState, { + platform: "linux", procRoot: "/fixture-proc", fsOps, + }); + assert.equal(markerScans, 4, + "successive runner/worker exits still require two final identical scans"); + assert.equal(treeState.knownPids.has(200), false); + assert.equal(treeState.knownPids.has(201), false); + assert.equal(treeState.processIdentityUncertain, undefined); +}); + +test("Linux ancestry remains uncertain when a parent exits with a pending child", async () => { + let rootStatReads = 0; + let markerScans = 0; + const fsOps = { + readdir: async (target) => { + if (target === "/fixture-proc/100/task") { + return [{ name: "100", isDirectory: () => true }]; + } + if (target === "/fixture-proc") { + markerScans += 1; + return []; + } + return []; + }, + readFile: async (target) => { + if (target.endsWith("/100/stat")) { + rootStatReads += 1; + if (rootStatReads === 1) { + return procStatLine(100, { parent: 1, group: 100, startIdentity: 10 }); + } + throw missingProcessError(); + } + if (target.endsWith("/100/task/100/children")) return "200\n"; + throw missingProcessError(); + }, + }; + const treeState = { + knownPids: new Set([100]), knownStarts: new Map([[100, "10"]]), + runMarker: "fixture-run", + }; + await refreshProcessTree({ pid: 100 }, treeState, { + platform: "linux", procRoot: "/fixture-proc", fsOps, + }); + assert.equal(markerScans, 2); + assert.equal(treeState.knownPids.has(200), false, + "the unverified pending child is never enrolled after its parent disappears"); + assert.equal(treeState.processIdentityUncertain, true, + "a pending child keeps containment fail-closed despite stable empty markers"); +}); + +test("Linux ancestry accepts an exiting parent whose pending child was already verified", async () => { + let rootStatReads = 0; + let markerScans = 0; + const fsOps = { + readdir: async (target) => { + if (target === "/fixture-proc/100/task") { + return [{ name: "100", isDirectory: () => true }]; + } + if (target === "/fixture-proc") { + markerScans += 1; + return []; + } + return []; + }, + readFile: async (target) => { + if (target.endsWith("/100/stat")) { + rootStatReads += 1; + if (rootStatReads === 1) { + return procStatLine(100, { parent: 1, group: 100, startIdentity: 10 }); + } + throw missingProcessError(); + } + if (target.endsWith("/100/task/100/children")) return "200\n"; + if (target.endsWith("/200/stat")) throw missingProcessError("ESRCH"); + throw missingProcessError(); + }, + }; + const treeState = { + knownPids: new Set([100, 200]), + knownStarts: new Map([[100, "10"], [200, "20"]]), + runMarker: "fixture-run", + }; + await refreshProcessTree({ pid: 100 }, treeState, { + platform: "linux", procRoot: "/fixture-proc", fsOps, + }); + assert.equal(markerScans, 2, + "the parent exit still requires two identical full marker observations"); + assert.equal(treeState.processIdentityUncertain, undefined, + "an immutable known identity is not an unverified pending child"); +}); + +test("Linux ancestry retries a torn task sample while its parent identity remains stable", async () => { + let rootStatReads = 0; + let markerScans = 0; + let taskScans = 0; + const fsOps = { + readdir: async (target) => { + if (target === "/fixture-proc/100/task") { + taskScans += 1; + return [{ name: taskScans === 1 ? "101" : "100", isDirectory: () => true }]; + } + if (target === "/fixture-proc") { + markerScans += 1; + return []; + } + return []; + }, + readFile: async (target) => { + if (target.endsWith("/100/stat")) { + rootStatReads += 1; + return procStatLine(100, { parent: 1, group: 100, startIdentity: 10 }); + } + if (target.endsWith("/100/task/101/children")) throw missingProcessError(); + if (target.endsWith("/100/task/100/children")) return "\n"; + throw missingProcessError(); + }, + }; + const treeState = { + knownPids: new Set([100]), knownStarts: new Map([[100, "10"]]), + runMarker: "fixture-run", + }; + const snapshot = await refreshProcessTree({ pid: 100 }, treeState, { + platform: "linux", procRoot: "/fixture-proc", fsOps, + }); + assert.equal(rootStatReads, 3); + assert.equal(taskScans, 2); + assert.equal(markerScans, 1, + "a live verified parent recovers markers before retrying its complete task list"); + assert.deepEqual(snapshot.map((item) => item.pid), [100]); + assert.equal(treeState.processIdentityUncertain, undefined); +}); + +test("Linux ancestry preserves children observed before repeated task-list churn", async () => { + let rootAlive = true; + const fsOps = { + readdir: async (target) => { + if (target === "/fixture-proc/100/task") { + return [100, 101].map((pid) => ({ name: String(pid), isDirectory: () => true })); + } + if (target === "/fixture-proc/200/task") return []; + if (target === "/fixture-proc") { + return [{ name: "200", isDirectory: () => true }]; + } + return []; + }, + readFile: async (target) => { + if (target.endsWith("/100/stat")) { + if (!rootAlive) throw missingProcessError(); + return procStatLine(100, { parent: 1, group: 100, startIdentity: 10 }); + } + if (target.endsWith("/200/stat")) { + return procStatLine(200, { + parent: rootAlive ? 100 : 1, group: 200, startIdentity: 20, + }); + } + if (target.endsWith("/100/task/100/children")) return "200\n"; + if (target.endsWith("/100/task/101/children")) throw missingProcessError(); + if (target.endsWith("/200/environ")) return Buffer.from("PATH=/fixture\0"); + throw missingProcessError(); + }, + }; + const treeState = { + knownPids: new Set([100]), knownStarts: new Map([[100, "10"]]), + runMarker: "fixture-run", markerObservationGraceMs: 0, + }; + await refreshProcessTree({ pid: 100 }, treeState, { + platform: "linux", procRoot: "/fixture-proc", fsOps, + }); + assert.equal(treeState.knownPids.has(200), true, + "a child from the successful task read survives later torn task reads"); + assert.equal(treeState.knownStarts.get(200), "20"); + assert.equal(treeState.processIdentityUncertain, true, + "repeatedly incomplete task enumeration remains fail-closed"); + + rootAlive = false; + const alive = await isProcessTreeAlive({ pid: 100 }, treeState, { + platform: "linux", procRoot: "/fixture-proc", fsOps, + probeProcessGroup: () => { throw Object.assign(new Error("gone"), { code: "ESRCH" }); }, + }); + assert.equal(alive, true, + "the escaped unmarked child cannot make the uncertain tree look terminated"); +}); + +test("POSIX startup cannot bind a replacement after the child handle exits", async () => { + const child = { pid: 9380, exitCode: null, signalCode: null }; + const treeState = { knownPids: new Set([9380]), knownStarts: new Map() }; + await assert.rejects(initializeProcessTree(child, treeState, { + platform: "linux", + posixProcessSnapshot: async () => { + child.exitCode = 0; + return [{ + pid: 9380, parentPid: 1, processGroupId: 9380, + state: "S", startIdentity: "replacement", + }]; + }, + }), /identity was not captured while its process handle was live/iu); + assert.equal(treeState.knownStarts.size, 0); +}); + +test("POSIX startup fails closed when the root identity is unavailable", async () => { + const child = { pid: 9390, exitCode: null, signalCode: null }; + const treeState = { knownPids: new Set([9390]), knownStarts: new Map() }; + await assert.rejects(initializeProcessTree(child, treeState, { + platform: "linux", posixProcessSnapshot: async () => [], + }), /identity was not captured/iu); +}); + +test("POSIX startup fails closed when process enumeration is unavailable", async () => { + const child = { pid: 9391, exitCode: null, signalCode: null }; + const treeState = { knownPids: new Set([9391]), knownStarts: new Map() }; + await assert.rejects(initializeProcessTree(child, treeState, { + platform: "linux", posixProcessSnapshot: async () => null, + }), /identity was not captured/iu); +}); + +test("an unavailable POSIX snapshot keeps an escaped tree fail-closed", async () => { + const child = { pid: 9392 }; + const treeState = { + knownPids: new Set([9392, 9393]), + knownStarts: new Map([[9392, "root"], [9393, "escaped"]]), + }; + assert.equal(await isProcessTreeAlive(child, treeState, { + platform: "linux", posixProcessSnapshot: async () => null, + probeProcessGroup: () => { throw Object.assign(new Error("gone"), { code: "ESRCH" }); }, + }), true); +}); + +test("a verified surviving member anchors its original POSIX process group", async () => { + const child = { pid: 9395 }; + const treeState = { + knownPids: new Set([9395, 9396]), + knownStarts: new Map([[9395, "root"], [9396, "anchor"]]), + }; + const snapshot = snapshotOf([ + { pid: 9396, parentPid: 1, processGroupId: 9395, state: "Z", startIdentity: "anchor" }, + { pid: 9397, parentPid: 1, processGroupId: 9395, state: "S", startIdentity: "sibling" }, + ]); + assert.equal(await isProcessTreeAlive(child, treeState, { + platform: "linux", posixProcessSnapshot: snapshot, ignoreZombieOnly: true, + }), true, "a live sibling keeps the identity-anchored original group alive"); + const groupSignals = []; + await signalProcessTree(child, "SIGKILL", treeState, { + platform: "linux", posixProcessSnapshot: snapshot, + killGroup: (pid) => { groupSignals.push(pid); }, killOne: () => {}, + }); + assert.deepEqual(groupSignals, [9395], "group signaling covers siblings missed by ancestry polling"); +}); + +test("tracked POSIX PIDs absent from a successful signal snapshot are skipped", async () => { + const child = { pid: 9400 }; + const treeState = { + knownPids: new Set([9400, 9401]), + knownStarts: new Map([[9400, "leader"], [9401, "exited-child"]]), + }; + const snapshot = snapshotOf([ + { pid: 9400, parentPid: 1, processGroupId: 9400, state: "S", startIdentity: "leader" }, + ]); + const oneSignals = []; + await signalProcessTree(child, "SIGTERM", treeState, { + platform: "linux", posixProcessSnapshot: snapshot, + killGroup: () => {}, killOne: (pid) => { oneSignals.push(pid); }, + }); + assert.deepEqual(oneSignals, [], "the stale numeric PID could already belong to another process"); +}); + +test("windows tree inspection drops known PIDs whose creation identity changed", async () => { + const treeState = { + knownPids: new Set([500, 501]), + knownStarts: new Map([[500, "100"], [501, "200"]]), + }; + const fakeUtility = async (command, args) => { + assert.match(args.join(" "), /CreationTicks/u, "the CIM projection must request creation times"); + return { + exitCode: 0, + stdout: JSON.stringify([ + { ProcessId: 500, ParentProcessId: 1, CreationTicks: "300" }, + { ProcessId: 501, ParentProcessId: 500, CreationTicks: "200" }, + { ProcessId: 502, ParentProcessId: 501, CreationTicks: "400" }, + ]), + stderr: "", + }; + }; + const pids = await windowsProcessTreePids(500, treeState, { runUtility: fakeUtility }); + assert.ok(pids.includes(501) && pids.includes(502), "genuine descendants are kept"); + assert.ok(!pids.includes(500), "the reused root PID is dropped from the tree"); + assert.ok(!treeState.knownPids.has(500), "the reused PID leaves the tracked set"); + assert.equal(treeState.knownStarts.get(500), "100", "the original identity is retained for comparison"); +}); + +test("Windows startup binds the root identity only while its child handle is live", async () => { + const live = { pid: 10, exitCode: null, signalCode: null }; + const liveState = { knownPids: new Set([10]), knownStarts: new Map() }; + await initializeProcessTree(live, liveState, { + platform: "win32", queryRootIdentity: async () => "100", + }); + assert.equal(liveState.knownStarts.get(10), "100"); + + const exited = { pid: 20, exitCode: null, signalCode: null }; + const exitedState = { knownPids: new Set([20]), knownStarts: new Map() }; + await assert.rejects(initializeProcessTree(exited, exitedState, { + platform: "win32", + queryRootIdentity: async () => { + exited.exitCode = 0; + return "200"; + }, + }), /exited before startup identity/iu); + assert.equal(exitedState.knownStarts.has(20), false); +}); + +test("Windows tracking fails closed after startup identity capture fails", async () => { + const child = { pid: 10, exitCode: 0, signalCode: null }; + const treeState = { + knownPids: new Set([10]), knownStarts: new Map(), + windowsRootIdentityAttempted: true, + }; + await assert.rejects(isProcessTreeAlive(child, treeState, { + platform: "win32", windowsSnapshot: async () => [], + }), /identity was not captured/iu); +}); + +test("Windows tracking rejects children older than their verified parent", async () => { + const child = { pid: 10, exitCode: null, signalCode: null }; + const treeState = { + knownPids: new Set([10]), knownStarts: new Map([[10, "300"]]), + }; + assert.equal(await isProcessTreeAlive(child, treeState, { + platform: "win32", + windowsSnapshot: async () => [ + { pid: 10, parentPid: 1, startIdentity: "300" }, + { pid: 20, parentPid: 10, startIdentity: "200" }, + ], + }), true); + assert.equal(treeState.knownPids.has(20), false); +}); + +test("Windows tracking fails closed for a new child of a disappeared known parent", async () => { + const child = { pid: 10, exitCode: 0, signalCode: null }; + const treeState = { + knownPids: new Set([10, 20]), + knownStarts: new Map([[10, "100"], [20, "200"]]), + }; + await assert.rejects(isProcessTreeAlive(child, treeState, { + platform: "win32", + windowsSnapshot: async () => [{ pid: 30, parentPid: 20, startIdentity: "300" }], + }), /unverified descendant remained/iu); +}); + +test("Windows tracking fails closed for an older child of a reused parent PID", async () => { + const child = { pid: 10, exitCode: 0, signalCode: null }; + const treeState = { + knownPids: new Set([10, 20]), + knownStarts: new Map([[10, "100"], [20, "200"]]), + }; + await assert.rejects(isProcessTreeAlive(child, treeState, { + platform: "win32", + windowsSnapshot: async () => [ + { pid: 20, parentPid: 1, startIdentity: "400" }, + { pid: 30, parentPid: 20, startIdentity: "300" }, + ], + }), /unverified descendant remained/iu); +}); + +test("Windows signaling never binds or kills a reused root after exit", async () => { + const child = { pid: 10, exitCode: 0, signalCode: null }; + const treeState = { knownPids: new Set([10]), knownStarts: new Map() }; + const killed = []; + await assert.rejects(signalProcessTree(child, "SIGKILL", treeState, { + platform: "win32", + windowsSnapshot: async () => [{ pid: 10, parentPid: 1, startIdentity: "300" }], + taskkill: async (pid) => { killed.push(pid); }, + taskkillTree: async (pid) => { killed.push(pid); }, + }), /identity was not captured/iu); + assert.deepEqual(killed, []); +}); + +test("Windows tree signaling revalidates the live root before taskkill tree", async () => { + const child = { pid: 10, exitCode: null, signalCode: null }; + const treeState = { + knownPids: new Set([10]), knownStarts: new Map([[10, "100"]]), + }; + const killedTrees = []; + await assert.rejects(signalProcessTree(child, "SIGKILL", treeState, { + platform: "win32", + queryRootIdentity: async () => "101", + windowsSnapshot: async () => [], + taskkillTree: async (pid) => { killedTrees.push(pid); }, + }), /creation identity changed/iu); + assert.deepEqual(killedTrees, []); +}); + +test("the group is still signaled when process enumeration is unavailable", async () => { + const child = { pid: 9200 }; + const treeState = { knownPids: new Set([9200]), knownStarts: new Map() }; + await refreshProcessTree(child, treeState, { + platform: "linux", + posixProcessSnapshot: async () => [{ pid: 9200, parentPid: 1, processGroupId: 9200, state: "S", startIdentity: "start-z" }], + allowRootIdentityCapture: true, + }); + const groupSignals = []; + await signalProcessTree(child, "SIGTERM", treeState, { + platform: "linux", + posixProcessSnapshot: async () => null, + killGroup: (pgid) => { groupSignals.push(pgid); }, + killOne: () => {}, + }); + assert.deepEqual(groupSignals, [9200], + "containment wins when identity cannot be verified: the group is signaled"); +}); + +test("BSD process snapshots reject truncation and malformed records", async () => { + const valid = "10 1 10 S Mon Jan 1 00:00:00 2024\n"; + assert.equal(await posixProcessSnapshot({ + platform: "darwin", + runUtility: async () => ({ exitCode: 0, stdout: valid, stdoutTruncated: true }), + }), null); + assert.equal(await posixProcessSnapshot({ + platform: "darwin", + runUtility: async () => ({ exitCode: 0, stdout: valid + "malformed\n" }), + }), null); +}); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs new file mode 100644 index 0000000..34b9960 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -0,0 +1,4552 @@ +import assert from "node:assert/strict"; +import { execFile, spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { access, chmod, copyFile, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { createServer } from "node:net"; +import { createInterface } from "node:readline"; +import test from "node:test"; +import { promisify } from "node:util"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { + localHostIdentity, WORKSPACE_LOCK_REF_PREFIX, workspaceLockRef, +} from "../workspace-lock.mjs"; +import { + resolvePathCommand, safeGitInvocation, subscribePathCommand, subscribeTrustedGitExecutable, + trustedGitExecutable, +} from "../git-executable.mjs"; +import { + backendConfigurationControlEnvironment, backendConfigurationReaderEnvironment, + backendEntryFromProbe, backendGitProvenanceEnvironment, + closestExistingBase, committedDelta, + gitCommonDirectory, gitWorktreeRoot, loadBackends, markWorkspaceQuarantined, + populateCommitishCache, readBackendGitProvenance, readBoundedRegularFile, + readRepositoryLockActivity, readWorkspaceQuarantine, repositoryLockKey, + runCommand, runGitCommand, startStdioServer, +} from "../server.mjs"; + +const execFileAsync = promisify(execFile); +const testsRoot = path.dirname(fileURLToPath(import.meta.url)); +const pluginRoot = path.resolve(testsRoot, ".."); +const serverPath = path.join(pluginRoot, "server.mjs"); +const fakeBackendPath = path.join(testsRoot, "fake-backend.mjs"); +const requestKey = (id) => typeof id + ":" + String(id); + +test("failed backend command resolutions are retried after installation", async (context) => { + const binRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-resolution-retry-test-")); + context.after(() => rm(binRoot, { recursive: true, force: true })); + const originalPath = process.env.PATH; + process.env.PATH = binRoot + path.delimiter + (originalPath ?? ""); + context.after(() => { process.env.PATH = originalPath; }); + const command = "late-installed-backend-" + String(process.pid) + "-" + String(Date.now()); + const first = resolvePathCommand(command); + const concurrent = resolvePathCommand(command); + assert.equal(first, concurrent, "concurrent callers should share one filesystem lookup"); + assert.deepEqual(await Promise.all([first, concurrent]), [null, null]); + + const executable = path.join(binRoot, command + (process.platform === "win32" ? ".exe" : "")); + await copyFile(process.execPath, executable); + if (process.platform !== "win32") await chmod(executable, 0o755); + assert.equal(await resolvePathCommand(command), await realpath(executable)); +}); + +test("abandoned command-resolution waiters detach from the shared lookup", async (context) => { + const binRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-resolution-waiter-test-")); + context.after(() => rm(binRoot, { recursive: true, force: true })); + const originalPath = process.env.PATH; + const originalNodeEnv = process.env.NODE_ENV; + const originalDelay = process.env.CLI_AGENT_BRIDGE_TEST_COMMAND_RESOLUTION_DELAY_MS; + process.env.PATH = binRoot + path.delimiter + (originalPath ?? ""); + process.env.NODE_ENV = "test"; + process.env.CLI_AGENT_BRIDGE_TEST_COMMAND_RESOLUTION_DELAY_MS = "100"; + context.after(() => { + process.env.PATH = originalPath; + if (originalNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = originalNodeEnv; + if (originalDelay === undefined) delete process.env.CLI_AGENT_BRIDGE_TEST_COMMAND_RESOLUTION_DELAY_MS; + else process.env.CLI_AGENT_BRIDGE_TEST_COMMAND_RESOLUTION_DELAY_MS = originalDelay; + }); + const command = "detachable-backend-" + String(process.pid) + "-" + String(Date.now()); + const executable = path.join(binRoot, command + (process.platform === "win32" ? ".exe" : "")); + await copyFile(process.execPath, executable); + if (process.platform !== "win32") await chmod(executable, 0o755); + let callbacks = 0; + const unsubscribe = subscribePathCommand( + command, + () => { callbacks += 1; }, + () => { callbacks += 1; }, + ); + unsubscribe(); + assert.equal(await resolvePathCommand(command), await realpath(executable)); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(callbacks, 0, "a cancelled/deadline waiter must not be retained until core lookup settles"); +}); + +test("trusted Git resolution caches success while abandoned waiters stay detached", async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), "cli-agent-git-entry-test-")); + const startedFile = path.join(root, "started.txt"); + context.after(() => rm(root, { recursive: true, force: true })); + const saved = { + nodeEnv: process.env.NODE_ENV, + delay: process.env.CLI_AGENT_BRIDGE_TEST_GIT_RESOLUTION_DELAY_MS, + started: process.env.CLI_AGENT_BRIDGE_TEST_GIT_RESOLUTION_STARTED_FILE, + }; + process.env.NODE_ENV = "test"; + process.env.CLI_AGENT_BRIDGE_TEST_GIT_RESOLUTION_DELAY_MS = "100"; + process.env.CLI_AGENT_BRIDGE_TEST_GIT_RESOLUTION_STARTED_FILE = startedFile; + context.after(() => { + if (saved.nodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = saved.nodeEnv; + if (saved.delay === undefined) delete process.env.CLI_AGENT_BRIDGE_TEST_GIT_RESOLUTION_DELAY_MS; + else process.env.CLI_AGENT_BRIDGE_TEST_GIT_RESOLUTION_DELAY_MS = saved.delay; + if (saved.started === undefined) delete process.env.CLI_AGENT_BRIDGE_TEST_GIT_RESOLUTION_STARTED_FILE; + else process.env.CLI_AGENT_BRIDGE_TEST_GIT_RESOLUTION_STARTED_FILE = saved.started; + }); + + let abandonedCallbacks = 0; + const unsubscribePending = subscribeTrustedGitExecutable( + () => { abandonedCallbacks += 1; }, () => { abandonedCallbacks += 1; }, + ); + unsubscribePending(); + const first = await trustedGitExecutable(); + assert.equal(first, await realpath(first)); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(abandonedCallbacks, 0); + assert.equal((await readFile(startedFile, "utf8")).trim().split(/\r?\n/u).length, 1); + assert.equal(await trustedGitExecutable(), first, "the canonical positive result must stay cached"); + assert.equal((await readFile(startedFile, "utf8")).trim().split(/\r?\n/u).length, 1, + "a positive cached result must not restart filesystem resolution"); + + const unsubscribeSettled = subscribeTrustedGitExecutable( + () => { abandonedCallbacks += 1; }, () => { abandonedCallbacks += 1; }, + ); + unsubscribeSettled(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(abandonedCallbacks, 0, + "same-tick unsubscribe must suppress an already-settled queued callback"); +}); + +test("failed trusted Git resolutions are retried", async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), "cli-agent-git-retry-test-")); + context.after(() => rm(root, { recursive: true, force: true })); + const savedPath = process.env.PATH; + process.env.PATH = root; + context.after(() => { process.env.PATH = savedPath; }); + const module = await import(new URL( + "../git-executable.mjs?git-retry=" + encodeURIComponent(String(Date.now())), import.meta.url, + )); + await assert.rejects(module.trustedGitExecutable(), /cannot locate git/iu); + const executable = path.join(root, process.platform === "win32" ? "git.exe" : "git"); + await writeFile(executable, "fixture\n"); + if (process.platform !== "win32") await chmod(executable, 0o755); + assert.equal(await module.trustedGitExecutable(), await realpath(executable)); +}); + +test("safe Git invocations use an unpopulatable hook sink and ignore inherited repositories", async (context) => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-safe-git-env-test-")); + const workspace = path.join(tempRoot, "workspace"); + const unrelated = path.join(tempRoot, "unrelated.git"); + await mkdir(workspace); + await initializeFixtureRepository(workspace); + await execFileAsync("git", ["init", "--bare", unrelated]); + context.after(() => rm(tempRoot, { recursive: true, force: true })); + + const invocation = await safeGitInvocation(["rev-parse", "--git-common-dir"], { + ...process.env, + GIT_COMMON_DIR: unrelated, + Git_Dir: unrelated, + GIT_NAMESPACE: "foreign-namespace", + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "core.hooksPath", + GIT_CONFIG_VALUE_0: path.join(tempRoot, "attacker-controlled-hooks"), + }); + assert.ok(invocation.args.includes("core.hooksPath=/dev/null")); + assert.deepEqual( + Object.keys(invocation.env).filter((name) => /^GIT_/iu.test(name)).sort(), + ["GIT_OPTIONAL_LOCKS", "GIT_PAGER"], + ); + const { stdout } = await execFileAsync(invocation.command, invocation.args, { + cwd: workspace, + env: invocation.env, + }); + assert.equal( + await realpath(path.resolve(workspace, stdout.trim())), + await realpath(path.join(workspace, ".git")), + ); + + const tracePath = path.join(tempRoot, "packet.trace"); + const backendEnv = backendGitProvenanceEnvironment(tracePath, { + ...process.env, + GIT_COMMON_DIR: unrelated, + GIT_DIR: unrelated, + GIT_WORK_TREE: tempRoot, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "core.worktree", + GIT_CONFIG_VALUE_0: tempRoot, + GIT_REFLOG_ACTION: "fetch", + GIT_TRACE2_EVENT: path.join(tempRoot, "attacker-trace2"), + git_config_count: "1", + git_config_key_0: "core.abbrev", + git_config_value_0: "12", + }); + assert.equal(backendEnv.GIT_COMMON_DIR, undefined); + assert.equal(backendEnv.GIT_DIR, undefined); + assert.equal(backendEnv.GIT_WORK_TREE, undefined); + assert.equal(backendEnv.GIT_CONFIG_COUNT, undefined); + assert.equal(backendEnv.GIT_REFLOG_ACTION, undefined); + assert.equal(backendEnv.GIT_TRACE_PACKET, undefined); + assert.equal(backendEnv.GIT_TRACE2_EVENT, tracePath); + const backendDiscovery = await execFileAsync(invocation.command, ["rev-parse", "--git-common-dir"], { + cwd: workspace, env: backendEnv, + }); + assert.equal( + await realpath(path.resolve(workspace, backendDiscovery.stdout.trim())), + await realpath(path.join(workspace, ".git")), + ); +}); + +test("delegated workers ignore inherited Git routing while preserving authentication", async (context) => { + const unrelatedRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-worker-routing-test-")); + const unrelated = path.join(unrelatedRoot, "unrelated"); + await mkdir(unrelated); + await initializeFixtureRepository(unrelated); + context.after(() => rm(unrelatedRoot, { recursive: true, force: true })); + const { stdout: unrelatedHeadText } = await execFileAsync( + "git", ["rev-parse", "HEAD"], { cwd: unrelated }, + ); + const authentication = { + GIT_SSH_COMMAND: "ssh -F preserved-fixture-config", + GH_TOKEN: "preserved-fixture-token", + }; + const { tempRoot, workspace, client } = await makeHarness(context, { + extraEnv: { + GIT_DIR: path.join(unrelated, ".git"), + GIT_WORK_TREE: unrelated, + GIT_INDEX_FILE: path.join(unrelated, ".git", "index"), + GIT_CEILING_DIRECTORIES: unrelatedRoot, + GIT_NO_REPLACE_OBJECTS: null, + ...authentication, + }, + }); + const environmentFile = path.join(tempRoot, "delegated-environment.json"); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "sanitized-worker-routing", + commitCurrent: true, + writeFile: "target-worker.txt", + commitMessage: "commit in requested workspace", + environmentFile, + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out)); + assert.match(out.commits.log, /commit in requested workspace/u); + await access(path.join(workspace, "target-worker.txt")); + await assert.rejects(access(path.join(unrelated, "target-worker.txt")), /ENOENT/u); + const { stdout: unrelatedAfter } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: unrelated, + }); + assert.equal(unrelatedAfter.trim(), unrelatedHeadText.trim()); + assert.deepEqual(JSON.parse(await readFile(environmentFile, "utf8")), { + GIT_DIR: null, + GIT_WORK_TREE: null, + GIT_INDEX_FILE: null, + GIT_CEILING_DIRECTORIES: null, + GIT_NO_REPLACE_OBJECTS: null, + ...authentication, + }); +}); + +function unconfirmedGitResult(overrides = {}) { + return { + stdout: "", stderr: "", exitCode: null, timedOut: false, killed: true, + orphanedProcesses: true, treeTerminated: false, + terminationError: "fixture descendants remain uncertain", + errorMessage: "", spawnError: null, + stdoutTruncated: false, stderrTruncated: false, + ...overrides, + }; +} + +test("repository activity snapshots read history before a separate live-ref scan", async () => { + const historyRef = workspaceLockRef("fixture-ordered-history") + ".history"; + const activeRef = workspaceLockRef("fixture-ordered-active"); + const historyOid = "1".repeat(40); + const activeOid = "2".repeat(40); + let calls = 0; + const activity = await readRepositoryLockActivity(pluginRoot, null, { + commandRunner: async () => { + calls += 1; + return unconfirmedGitResult({ + exitCode: 0, + killed: false, + orphanedProcesses: false, + treeTerminated: true, + terminationError: "", + stdout: calls === 1 + ? historyRef + "\t" + historyOid + "\n" + : historyRef + "\t" + historyOid + "\n" + activeRef + "\t" + activeOid + "\n", + }); + }, + }); + assert.equal(calls, 2, "history and live refs must not share a non-atomic enumeration"); + assert.equal(activity.historyRefs.get(historyRef), historyOid); + assert.equal(activity.activeRefs, 1, + "a lease created after history capture must be visible in the later live scan"); +}); + +test("Git interruption never outruns unconfirmed descendant quarantine", async () => { + const cancelled = { cancelled: false, controller: null }; + const cancelledResult = await runGitCommand(["version"], { + cwd: pluginRoot, + cancel: cancelled, + containProcessTree: true, + commandRunner: async () => { + cancelled.cancelled = true; + return unconfirmedGitResult(); + }, + }); + assert.equal(cancelledResult.treeTerminated, false, + "the snapshot caller must receive the unsafe cleanup result before cancellation is reported"); + + const deadline = Date.now() + 40; + const deadlineResult = await runGitCommand(["version"], { + cwd: pluginRoot, + deadline, + containProcessTree: true, + commandRunner: async () => { + await new Promise((resolve) => setTimeout(resolve, 60)); + return unconfirmedGitResult({ timedOut: true }); + }, + }); + assert.equal(deadlineResult.treeTerminated, false, + "the snapshot caller must receive the unsafe cleanup result before the deadline is reported"); +}); + +test("Git commands do not launch after invocation setup crosses the deadline", async (context) => { + const savedNodeEnv = process.env.NODE_ENV; + const savedDelay = process.env.CLI_AGENT_BRIDGE_TEST_GIT_INVOCATION_DELAY_MS; + await trustedGitExecutable(); + process.env.NODE_ENV = "test"; + process.env.CLI_AGENT_BRIDGE_TEST_GIT_INVOCATION_DELAY_MS = "100"; + context.after(() => { + if (savedNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = savedNodeEnv; + if (savedDelay === undefined) delete process.env.CLI_AGENT_BRIDGE_TEST_GIT_INVOCATION_DELAY_MS; + else process.env.CLI_AGENT_BRIDGE_TEST_GIT_INVOCATION_DELAY_MS = savedDelay; + }); + let runnerCalls = 0; + await assert.rejects(runGitCommand(["version"], { + cwd: pluginRoot, + deadline: Date.now() + 50, + commandRunner: async () => { + runnerCalls += 1; + return unconfirmedGitResult({ treeTerminated: true, terminationError: "" }); + }, + }), /deadline/iu); + assert.equal(runnerCalls, 0); +}); + +test("Git path canonicalization preserves cancellation and deadline errors", async () => { + const gitResult = (stdout) => unconfirmedGitResult({ + stdout, stderr: "", exitCode: 0, timedOut: false, killed: false, + orphanedProcesses: false, treeTerminated: true, terminationError: "", + }); + + const cancelled = testCancellation(); + let resolveWorktreeStarted; + const worktreeStarted = new Promise((resolve) => { resolveWorktreeStarted = resolve; }); + const worktree = gitWorktreeRoot(pluginRoot, { + cancel: cancelled, + commandRunner: async () => gitResult(pluginRoot + "\n"), + fsOps: { + realpath: () => { + resolveWorktreeStarted(); + return new Promise(() => {}); + }, + }, + }); + await worktreeStarted; + cancelled.cancel(); + await assert.rejects(worktree, (error) => + /cancelled/iu.test(error.message) && !/canonicalize/iu.test(error.message)); + + let resolveCommonStarted; + const commonStarted = new Promise((resolve) => { resolveCommonStarted = resolve; }); + const common = gitCommonDirectory(pluginRoot, { + deadline: Date.now() + 250, + commandRunner: async () => gitResult(".git\n"), + fsOps: { + realpath: () => { + resolveCommonStarted(); + return new Promise(() => {}); + }, + }, + }); + await commonStarted; + await assert.rejects(common, (error) => + /deadline/iu.test(error.message) && !/canonicalize/iu.test(error.message)); +}); + +test("committed-delta baseline preparation uses bounded batch queries", async () => { + const target = "a".repeat(40); + const boundary = "b".repeat(40); + const baselines = Array.from({ length: 1_000 }, (_, index) => + index.toString(16).padStart(40, "0")); + const invocations = []; + const cache = new Map(); + await populateCommitishCache(pluginRoot, baselines, cache, { + commandRunner: async (_command, args, options) => { + invocations.push({ args, stdinText: options.stdinText }); + const stdout = options.stdinText.trim().split("\n").map((revision) => + revision.replace(/\^\{commit\}$/u, "") + " commit").join("\n") + "\n"; + return { + ...unconfirmedGitResult(), stdout, exitCode: 0, killed: false, + orphanedProcesses: false, treeTerminated: true, terminationError: "", + }; + }, + }); + assert.equal(cache.size, baselines.length); + assert.equal(invocations.length, 1, "ref count must not determine cat-file process count"); + + const selected = await closestExistingBase(pluginRoot, target, baselines, { + commandRunner: async (_command, args, options) => { + invocations.push({ args, stdinText: options.stdinText }); + return { + ...unconfirmedGitResult(), + stdout: target + "\n-" + boundary + "\n", + exitCode: 0, + killed: false, + orphanedProcesses: false, + treeTerminated: true, + terminationError: "", + }; + }, + }); + assert.equal(selected, boundary); + assert.equal(invocations.length, 2, "baseline selection must add only one graph process"); + assert.equal(invocations[1].stdinText.trim().split("\n").length, baselines.length); + assert.ok(invocations[1].args.includes("--boundary")); +}); + +test("post-exit process-tree inspection failures settle fail-closed", async () => { + const result = await runCommand(process.execPath, ["-e", ""], { + manageProcessTree: true, + processTreeTestMode: true, + timeoutMs: 5_000, + initializeProcessTree: async () => {}, + inspectProcessTree: async () => { throw new Error("inspection unavailable"); }, + }); + assert.equal(result.treeTerminated, false); + assert.match(result.terminationError, /process-tree inspection failed: inspection unavailable/iu); +}); + +test("post-exit inspection waits for an in-flight ancestry refresh", async () => { + let refreshStartedResolve; + let releaseRefresh; + const refreshStarted = new Promise((resolve) => { refreshStartedResolve = resolve; }); + const refreshGate = new Promise((resolve) => { releaseRefresh = resolve; }); + let inspectedAfterRefresh = false; + let resolved = false; + const pending = runCommand(process.execPath, ["-e", "setTimeout(()=>{},1300)"], { + manageProcessTree: true, + processTreeTestMode: true, + timeoutMs: 5_000, + initializeProcessTree: async () => {}, + refreshProcessTree: async (_child, state) => { + refreshStartedResolve(); + await refreshGate; + state.completedRefresh = true; + }, + inspectProcessTree: async (_child, state) => { + inspectedAfterRefresh = state.completedRefresh === true; + return false; + }, + }); + void pending.then(() => { resolved = true; }); + await refreshStarted; + await new Promise((resolve) => setTimeout(resolve, 1_500)); + assert.equal(resolved, false, "close must not settle ahead of the active ancestry refresh"); + releaseRefresh(); + const result = await pending; + assert.equal(result.treeTerminated, true); + assert.equal(inspectedAfterRefresh, true); +}); + +test("periodic process-tree polling starts only after initialization", async () => { + let releaseInitialization; + const initializationGate = new Promise((resolve) => { releaseInitialization = resolve; }); + let initializing = true; + let refreshCalls = 0; + let refreshRaced = false; + const pending = runCommand(process.execPath, ["-e", "setTimeout(()=>{},1200)"], { + manageProcessTree: true, + processTreeTestMode: true, + timeoutMs: 5_000, + initializeProcessTree: async (_child, state) => { + await initializationGate; + state.fixtureRootIdentity = true; + initializing = false; + }, + refreshProcessTree: async (_child, state) => { + refreshCalls += 1; + if (initializing || state.fixtureRootIdentity !== true) refreshRaced = true; + }, + inspectProcessTree: async () => false, + }); + + // Windows test-mode polling uses the production 250 ms interval; hold the + // initializer beyond that boundary so this deterministically fails if the + // timer is ever installed before the startup barrier completes. + await new Promise((resolve) => setTimeout(resolve, 350)); + assert.equal(refreshCalls, 0, "the timer must not inspect a partially initialized tree"); + releaseInitialization(); + await waitFor(() => refreshCalls > 0); + const result = await pending; + assert.equal(refreshRaced, false); + assert.equal(result.treeTerminated, true, JSON.stringify(result)); +}); + +test("runner exit starts tree cleanup before escaped descendants close inherited streams", { + skip: process.platform === "win32" ? "POSIX process-group fixture" : false, +}, async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), "cli-agent-exit-before-close-test-")); + context.after(() => rm(root, { recursive: true, force: true })); + const pidFile = path.join(root, "escaped.pid"); + let escapedPid = null; + context.after(() => { + if (Number.isInteger(escapedPid)) { + try { process.kill(escapedPid, "SIGKILL"); } catch { /* already gone */ } + } + }); + const parentCode = [ + "const {spawn}=require('node:child_process')", + "const fs=require('node:fs')", + "const child=spawn(process.execPath,['-e','setTimeout(()=>{},20000)']," + + "{detached:true,stdio:['ignore',process.stdout,process.stderr]})", + `fs.writeFileSync(${JSON.stringify(pidFile)},String(child.pid))`, + "child.unref()", + ].join(";"); + const startedAt = Date.now(); + const result = await runCommand(process.execPath, ["-e", parentCode], { + cwd: root, + manageProcessTree: true, + timeoutMs: 3_000, + initializeProcessTree: async () => {}, + refreshProcessTree: async () => {}, + inspectProcessTree: async () => { + escapedPid = Number(await readFile(pidFile, "utf8")); + return true; + }, + signalProcessTree: async (_child, signal) => { + if (Number.isInteger(escapedPid)) { + try { process.kill(escapedPid, signal); } catch { /* already gone */ } + } + }, + waitForProcessTreeExit: async () => true, + }); + assert.equal(result.timedOut, false, JSON.stringify(result)); + assert.equal(result.orphanedProcesses, true); + assert.ok(Date.now() - startedAt < 2_500, + "tree inspection must start on exit instead of waiting for descendant-held stream handles"); +}); + +test("stream draining cannot finalize ahead of termination started after exit", async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), "cli-agent-drain-termination-race-test-")); + context.after(() => rm(root, { recursive: true, force: true })); + const pidFile = path.join(root, "stream-holder.pid"); + let escapedPid = null; + let controller; + let signalStartedResolve; + let releaseSignal; + const signalStarted = new Promise((resolve) => { signalStartedResolve = resolve; }); + const signalGate = new Promise((resolve) => { releaseSignal = resolve; }); + context.after(() => { + if (Number.isInteger(escapedPid)) { + try { process.kill(escapedPid, "SIGKILL"); } catch { /* already gone */ } + } + }); + const parentCode = [ + "const {spawn}=require('node:child_process')", + "const fs=require('node:fs')", + "const child=spawn(process.execPath,['-e','setTimeout(()=>{},20000)']," + + "{detached:true,stdio:['ignore',process.stdout,process.stderr]})", + `fs.writeFileSync(${JSON.stringify(pidFile)},String(child.pid))`, + "child.unref()", + ].join(";"); + let resolved = false; + const pending = runCommand(process.execPath, ["-e", parentCode], { + cwd: root, + manageProcessTree: true, + processTreeTestMode: true, + timeoutMs: 5_000, + killGraceMs: 250, + streamDrainMs: 250, + initializeProcessTree: async () => {}, + refreshProcessTree: async () => {}, + inspectProcessTree: async () => false, + signalProcessTree: async (_child, signal) => { + signalStartedResolve(); + await signalGate; + if (Number.isInteger(escapedPid)) { + try { process.kill(escapedPid, signal); } catch { /* already gone */ } + } + }, + waitForProcessTreeExit: async () => true, + onChild: (value) => { controller = value; }, + }); + void pending.then(() => { resolved = true; }); + await waitFor(async () => { + try { escapedPid = Number(await readFile(pidFile, "utf8")); return true; } catch { return false; } + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + const terminating = controller.terminate("cancelled"); + await signalStarted; + await new Promise((resolve) => setTimeout(resolve, 300)); + assert.equal(resolved, false, "stream timeout must still wait for active termination cleanup"); + releaseSignal(); + const result = await pending; + await terminating; + assert.equal(result.timedOut, false); + await waitFor(async () => { + try { + if (process.platform === "linux") { + const statLine = await readFile("/proc/" + String(escapedPid) + "/stat", "utf8"); + const state = statLine.slice(statLine.lastIndexOf(")") + 1).trim().split(/\s+/u)[0]; + return ["Z", "X", "x"].includes(state); + } + process.kill(escapedPid, 0); + return false; + } catch { return true; } + }); + await new Promise((resolve) => setTimeout(resolve, 500)); + escapedPid = null; +}); + +test("post-exit inspection cannot settle ahead of concurrent termination", async () => { + let controller; + let inspectionStartedResolve; + let releaseInspection; + let signalStartedResolve; + let releaseSignal; + const inspectionStarted = new Promise((resolve) => { inspectionStartedResolve = resolve; }); + const inspectionGate = new Promise((resolve) => { releaseInspection = resolve; }); + const signalStarted = new Promise((resolve) => { signalStartedResolve = resolve; }); + const signalGate = new Promise((resolve) => { releaseSignal = resolve; }); + let resolved = false; + const pending = runCommand(process.execPath, ["-e", ""], { + manageProcessTree: true, + processTreeTestMode: true, + timeoutMs: 5_000, + initializeProcessTree: async () => {}, + refreshProcessTree: async () => {}, + inspectProcessTree: async () => { + inspectionStartedResolve(); + await inspectionGate; + return false; + }, + signalProcessTree: async () => { + signalStartedResolve(); + await signalGate; + }, + waitForProcessTreeExit: async () => true, + onChild: (value) => { controller = value; }, + }); + void pending.then(() => { resolved = true; }); + await inspectionStarted; + const terminating = controller.terminate("test-race"); + await signalStarted; + releaseInspection(); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(resolved, false, "close must await termination started during inspection"); + releaseSignal(); + const result = await pending; + await terminating; + assert.equal(result.treeTerminated, true); +}); + +test("cancellation published with the controller waits for tree initialization", async () => { + let cancellationChecks = 0; + let releaseInitialization; + let signalStarted = false; + const initializationGate = new Promise((resolve) => { releaseInitialization = resolve; }); + const pending = runCommand(process.execPath, ["-e", "process.exit(99)"], { + manageProcessTree: true, + processTreeTestMode: true, + timeoutMs: 5_000, + shouldCancel: () => { + cancellationChecks += 1; + return cancellationChecks >= 2; + }, + initializeProcessTree: async () => { await initializationGate; }, + signalProcessTree: async (child) => { + signalStarted = true; + try { child.kill("SIGKILL"); } catch { /* already gone */ } + }, + waitForProcessTreeExit: async () => true, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(signalStarted, false, "termination must not outrun startup identity capture"); + releaseInitialization(); + const result = await pending; + assert.equal(signalStarted, true); + assert.equal(result.timedOut, false); +}); + +test("the first termination reason remains authoritative", async () => { + let controller; + const pending = runCommand(process.execPath, ["-e", "setTimeout(()=>{},5000)"], { + manageProcessTree: true, + processTreeTestMode: true, + timeoutMs: 5_000, + killGraceMs: 250, + initializeProcessTree: async () => {}, + signalProcessTree: async (child, signal) => { + try { child.kill(signal); } catch { /* already gone */ } + }, + waitForProcessTreeExit: async () => true, + onChild: (value) => { controller = value; }, + }); + assert.ok(controller); + const cancelled = controller.terminate("cancelled"); + const sameTermination = controller.terminate("timeout"); + assert.equal(cancelled, sameTermination); + const result = await pending; + await cancelled; + assert.equal(result.timedOut, false, + "a later timer must not relabel an already-started cancellation as a timeout"); +}); + +test("process-tree refresh uncertainty is sticky and cleanup waits are bounded", async () => { + let refreshStarted = false; + const result = await runCommand(process.execPath, ["-e", "setTimeout(()=>{},400)"], { + manageProcessTree: true, + processTreeTestMode: true, + timeoutMs: 3_000, + killGraceMs: 250, + processTreeInspectionWaitMs: 50, + streamDrainMs: 50, + initializeProcessTree: async () => {}, + refreshProcessTree: async () => { + refreshStarted = true; + await new Promise(() => {}); + }, + signalProcessTree: async () => {}, + waitForProcessTreeExit: async () => true, + inspectProcessTree: async () => false, + }); + assert.equal(refreshStarted, true); + assert.equal(result.treeTerminated, false); + assert.match(result.terminationError, /process-tree refresh did not finish/iu); +}); + +test("a post-spawn child error cannot finalize ahead of termination cleanup", async () => { + let signalStartedResolve; + let releaseSignal; + const signalStarted = new Promise((resolve) => { signalStartedResolve = resolve; }); + const signalGate = new Promise((resolve) => { releaseSignal = resolve; }); + let resolved = false; + const pending = runCommand(process.execPath, ["-e", "setTimeout(()=>{},5000)"], { + manageProcessTree: true, + processTreeTestMode: true, + timeoutMs: 5_000, + killGraceMs: 250, + initializeProcessTree: async () => {}, + signalProcessTree: async (child, signal) => { + signalStartedResolve(); + await signalGate; + try { child.kill(signal); } catch { /* already gone */ } + }, + waitForProcessTreeExit: async () => true, + onChild: ({ child }) => { + setImmediate(() => { + const error = new Error("fixture post-spawn failure"); + error.code = "EIO"; + child.emit("error", error); + }); + }, + }); + void pending.then(() => { resolved = true; }); + await signalStarted; + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(resolved, false); + releaseSignal(); + const result = await pending; + assert.equal(result.treeTerminated, false); + assert.equal(result.spawnError, null, + "a runtime child error must never be mistaken for a pre-spawn failure and retried"); + assert.match(result.terminationError, /backend process error after spawn/iu); +}); + +test("a controller cannot terminate again after runCommand settles", async () => { + let controller; + let signals = 0; + const result = await runCommand(process.execPath, ["-e", ""], { + manageProcessTree: true, + processTreeTestMode: true, + timeoutMs: 5_000, + initializeProcessTree: async () => {}, + inspectProcessTree: async () => false, + signalProcessTree: async () => { signals += 1; }, + waitForProcessTreeExit: async () => true, + onChild: (value) => { controller = value; }, + }); + assert.equal(result.treeTerminated, true); + assert.equal(signals, 0); + await controller.terminate("late-ownership-loss"); + assert.equal(signals, 0, "a settled controller must never signal a reused numeric process id"); +}); + +test("Windows Job runner contains launch and preserves backend streams and exit code", { + skip: process.platform !== "win32", +}, async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), "cli-agent-job-runner-test-")); + context.after(() => rm(root, { recursive: true, force: true })); + const backend = path.join(root, "backend-fixture.cmd"); + await writeFile(path.join(root, "powershell.exe"), "workspace shadow must not execute\n"); + await writeFile(backend, [ + "@echo off", + "echo job-stdout-ok", + "echo job-stderr-ok 1>&2", + "exit /b 37", + "", + ].join("\r\n")); + const result = await runCommand(backend, [], { + cwd: root, manageProcessTree: true, timeoutMs: 5_000, + }); + assert.equal(result.exitCode, 37, JSON.stringify(result)); + assert.match(result.stdout, /job-stdout-ok/u); + assert.match(result.stderr, /job-stderr-ok/u); + assert.equal(result.treeTerminated, true); + + const exactArgument = "任务 \"quoted\" % (x) & caret^"; + const exact = await runCommand(process.execPath, [ + "-e", "process.stdout.write(process.argv[1])", exactArgument, + ], { cwd: root, manageProcessTree: true, timeoutMs: 5_000 }); + assert.equal(exact.exitCode, 0, JSON.stringify(exact)); + assert.equal(exact.stdout, exactArgument, + "the PowerShell containment layer must preserve UTF-8 and option-like task text exactly"); + + const scriptBackend = path.join(root, "echo-argument.ps1"); + await writeFile(scriptBackend, [ + "param([string]$Value)", + "[Console]::Out.Write($Value)", + "", + ].join("\r\n")); + const fallbackExact = await runCommand(scriptBackend, [exactArgument], { + cwd: root, manageProcessTree: true, timeoutMs: 5_000, + }); + assert.equal(fallbackExact.exitCode, 0, JSON.stringify(fallbackExact)); + assert.equal(fallbackExact.stdout, exactArgument, + "the contained PowerShell shim fallback must preserve the same argument bytes"); + + const argvFixture = path.join(root, "node_modules", "fixture", "argv-fixture.mjs"); + const cmdShim = path.join(root, "npm-style-shim.cmd"); + await mkdir(path.dirname(argvFixture), { recursive: true }); + await writeFile(argvFixture, + "process.stdout.write(JSON.stringify(process.argv.slice(2)));\n"); + await writeFile(cmdShim, [ + "@ECHO off", + "GOTO start", + ":find_dp0", + "SET dp0=%~dp0", + "EXIT /b", + ":start", + "SETLOCAL", + "CALL :find_dp0", + "", + "IF EXIST \"%dp0%\\node.exe\" (", + " SET \"_prog=%dp0%\\node.exe\"", + ") ELSE (", + " SET \"_prog=node\"", + " SET PATHEXT=%PATHEXT:;.JS;=;%", + ")", + "", + "endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & " + + "\"%_prog%\" \"%dp0%\\node_modules\\fixture\\argv-fixture.mjs\" %*", + "", + ].join("\r\n")); + const exactArguments = [ + "", " ", exactArgument, "tail\\", "two\\\\", "line1\nline2", "crlf1\r\ncrlf2", "汉🙂", + ]; + const shimExact = await runCommand(cmdShim, exactArguments, { + cwd: root, manageProcessTree: true, timeoutMs: 5_000, + }); + assert.equal(shimExact.exitCode, 0, JSON.stringify(shimExact)); + assert.deepEqual(JSON.parse(shimExact.stdout), exactArguments, + "the final Node process behind an npm-style .cmd shim must receive exact argv values"); + + const adjacentNode = path.join(root, "node.exe"); + await copyFile(process.execPath, adjacentNode); + await writeFile(argvFixture, "process.stdout.write(process.execPath);\n"); + const adjacentRuntime = await runCommand(cmdShim, [], { + cwd: root, manageProcessTree: true, timeoutMs: 5_000, + }); + assert.equal(adjacentRuntime.exitCode, 0, JSON.stringify(adjacentRuntime)); + assert.equal( + path.normalize(adjacentRuntime.stdout).toLowerCase(), + path.normalize(await realpath(adjacentNode)).toLowerCase(), + "a standard npm shim must honor its adjacent node.exe runtime", + ); + + const customCmd = await runCommand(backend, [exactArgument], { + cwd: root, manageProcessTree: true, timeoutMs: 5_000, + }); + assert.equal(customCmd.exitCode, 127, JSON.stringify(customCmd)); + assert.match(customCmd.stderr, /non-standard \.cmd\/\.bat backend/iu); + + const errorBackend = path.join(root, "stderr-and-exit.ps1"); + await writeFile(errorBackend, [ + "Write-Error 'fixture-nonterminating-error'", + "exit 37", + "", + ].join("\r\n")); + const errorResult = await runCommand(errorBackend, [], { + cwd: root, manageProcessTree: true, timeoutMs: 5_000, + }); + assert.equal(errorResult.exitCode, 37, JSON.stringify(errorResult)); + assert.match(errorResult.stderr, /fixture-nonterminating-error/iu); + + const handledNativeFailure = path.join(root, "handled-native-failure.ps1"); + await writeFile(handledNativeFailure, [ + "& $env:ComSpec /d /c 'exit 23' | Out-Null", + "[Console]::Out.Write('handled')", + "", + ].join("\r\n")); + const handledResult = await runCommand(handledNativeFailure, [], { + cwd: root, manageProcessTree: true, timeoutMs: 5_000, + }); + assert.equal(handledResult.exitCode, 0, JSON.stringify(handledResult)); + assert.equal(handledResult.stdout, "handled", + "a stale LASTEXITCODE must not override normal PowerShell script completion"); + + const throwingBackend = path.join(root, "throwing-backend.ps1"); + await writeFile(throwingBackend, "throw 'fixture-terminating-error'\r\n"); + const throwingResult = await runCommand(throwingBackend, [], { + cwd: root, manageProcessTree: true, timeoutMs: 5_000, + }); + assert.equal(throwingResult.exitCode, 127, JSON.stringify(throwingResult)); + assert.match(throwingResult.stderr, /fixture-terminating-error/iu); + + const invalidMarker = path.join(root, "invalid-argv-started.txt"); + const invalidArgument = await runCommand(process.execPath, [ + "-e", `require('node:fs').writeFileSync(${JSON.stringify(invalidMarker)}, 'started')`, + "A\0B", "tail", + ], { cwd: root, manageProcessTree: true, timeoutMs: 5_000 }); + assert.equal(invalidArgument.exitCode, 127, JSON.stringify(invalidArgument)); + assert.match(invalidArgument.stderr, /invalid process-tree runner command/iu); + await assert.rejects(access(invalidMarker), /ENOENT/u); + + const earlyExit = await runCommand(process.execPath, ["-e", "process.exit(0)"], { + cwd: root, manageProcessTree: true, stdinText: "x".repeat(1_000_000), timeoutMs: 5_000, + }); + assert.equal(earlyExit.exitCode, 0, JSON.stringify(earlyExit)); + + const source = await readFile(path.join(pluginRoot, "windows-job-runner.ps1"), "utf8"); + const containmentReady = source.indexOf( + "$jobHandle = [CliAgentBridgeJobObject]::CreateKillOnCloseJobForCurrentProcess()", + ); + const payloadRead = source.indexOf("[Console]::In.ReadToEnd()"); + const backendLaunch = source.indexOf("& $NodeExecutable $NodeRunner"); + assert.ok(containmentReady >= 0 && containmentReady < payloadRead && payloadRead < backendLaunch, + "Job containment must be live before the payload can launch a backend"); +}); + +test("a failed Windows containment runner never falls back to the backend", { + skip: process.platform !== "win32", +}, async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), "cli-agent-job-failure-test-")); + context.after(() => rm(root, { recursive: true, force: true })); + const marker = path.join(root, "backend-started.txt"); + const backend = path.join(root, "must-not-start.cmd"); + await writeFile(backend, `@echo started>${marker}\r\n`); + const failedRunner = path.join(root, "failed-job-runner.ps1"); + await writeFile(failedRunner, [ + "param([string]$NodeExecutable, [string]$NodeRunner)", + "$null = [Console]::In.ReadToEnd()", + "[Console]::Error.WriteLine('job-object initialization failed: fixture')", + "exit 125", + "", + ].join("\r\n")); + const result = await runCommand(backend, [], { + cwd: root, + manageProcessTree: true, + windowsJobRunnerPath: failedRunner, + timeoutMs: 5_000, + }); + assert.equal(result.exitCode, 125, JSON.stringify(result)); + assert.match(result.stderr, /job-object initialization failed/iu); + await assert.rejects(access(marker), /ENOENT/u); + + const missingRunner = await runCommand(process.execPath, [ + "-e", "process.exit(0)", "x".repeat(2_000_000), + ], { + cwd: root, + manageProcessTree: true, + windowsJobRunnerPath: path.join(root, "missing-job-runner.ps1"), + timeoutMs: 5_000, + }); + assert.notEqual(missingRunner.exitCode, 0, + "a bootstrap failure with a large pending payload must resolve without crashing the bridge"); +}); + +test("cancellation in the spawn-to-controller window never launches the backend", { + skip: process.platform !== "win32", +}, async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), "cli-agent-cancel-window-test-")); + context.after(() => rm(root, { recursive: true, force: true })); + const marker = path.join(root, "must-not-start.txt"); + let checks = 0; + const result = await runCommand(process.execPath, [ + "-e", `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'started')`, + ], { + cwd: root, + manageProcessTree: true, + timeoutMs: 5_000, + shouldCancel: () => { + checks += 1; + return checks >= 2; + }, + }); + assert.equal(result.timedOut, false, JSON.stringify(result)); + assert.equal(result.treeTerminated, true, JSON.stringify(result)); + await assert.rejects(access(marker), /ENOENT/u); +}); + +async function canonicalGitCommonDirectory(workspace) { + const { stdout } = await execFileAsync("git", ["rev-parse", "--git-common-dir"], { cwd: workspace }); + return await realpath(path.resolve(workspace, stdout.replace(/\r?\n$/u, ""))); +} + +async function coordinationLockStore(workspace) { + return path.join(await canonicalGitCommonDirectory(workspace), "cli-agent-bridge-lock-store.git"); +} + +async function repositoryIdFromStore(store) { + const { stdout: oid } = await execFileAsync( + "git", ["rev-parse", "--verify", "refs/cli-agent-bridge/repository-id"], { cwd: store }, + ); + const { stdout: repositoryId } = await execFileAsync( + "git", ["cat-file", "blob", oid.trim()], { cwd: store }, + ); + return repositoryId.trim(); +} + +async function repositoryKey(canonicalGitCommonDir) { + const repositoryId = await repositoryIdFromStore(path.join( + canonicalGitCommonDir, "cli-agent-bridge-lock-store.git", + )); + return "git-common-dir-id:" + repositoryId; +} + +async function repositoryStatePaths(canonicalGitCommonDir) { + const key = await repositoryKey(canonicalGitCommonDir); + const digest = createHash("sha256").update(key).digest("hex"); + const root = path.join( + canonicalGitCommonDir, "cli-agent-bridge-lock-store.git", "cli-agent-bridge-quarantines", + ); + return { + root, + quarantinePath: path.join(root, digest + ".quarantine"), + recoveryPath: path.join(root, digest + ".quarantine.recovery-approved"), + }; +} + +class McpClient { + constructor(configPath, extraEnv = {}) { + this.child = execServer(configPath, { + NODE_ENV: "test", + CLI_AGENT_BRIDGE_TEST_PROCESS_TREE_MODE: "1", + ...extraEnv, + }); + this.pending = new Map(); + this.stderr = ""; + this.nextId = 1; + this.child.stderr.setEncoding("utf8"); + this.child.stderr.on("data", (chunk) => { this.stderr += chunk; }); + const lines = createInterface({ input: this.child.stdout, crlfDelay: Infinity }); + lines.on("line", (line) => { + const message = JSON.parse(line); + const entry = this.pending.get(requestKey(message.id)); + if (!entry) return; + clearTimeout(entry.timer); + this.pending.delete(requestKey(message.id)); + entry.resolve(message); + }); + this.child.on("exit", (code) => { + for (const entry of this.pending.values()) { + clearTimeout(entry.timer); + entry.reject(new Error("server exited with code " + String(code) + ": " + this.stderr)); + } + this.pending.clear(); + }); + } + + async initialize() { + const response = await this.request("initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "cli-agent-bridge-test", version: "1.0.0" }, + }); + assert.equal(response.result.protocolVersion, "2025-06-18"); + this.notify("notifications/initialized", {}); + } + + request(method, params, id = this.nextId++) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(requestKey(id)); + reject(new Error("timed out waiting for request " + String(id) + ": " + this.stderr)); + }, 25_000); + this.pending.set(requestKey(id), { resolve, reject, timer }); + this.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + }); + } + + notify(method, params) { + this.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n"); + } + + async close() { + if (this.child.exitCode !== null) return; + this.child.kill(); + await new Promise((resolve) => this.child.once("exit", resolve)); + } + + async disconnectInput() { + if (this.child.exitCode !== null) return; + const exited = new Promise((resolve) => this.child.once("exit", resolve)); + this.child.stdin.end(); + let timer = null; + try { + await Promise.race([ + exited, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("server did not exit after stdin closed")), 15_000); + }), + ]); + } finally { + clearTimeout(timer); + } + } +} + +function execServer(configPath, extraEnv = {}) { + return spawn(process.execPath, [serverPath], { + env: { ...process.env, ...extraEnv, CLI_AGENT_BRIDGE_BACKENDS: configPath }, + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + }); +} + +async function makeHarness(context, { unborn = false, extraEnv = {} } = {}) { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-bridge-test-")); + const workspace = path.join(tempRoot, "workspace"); + await mkdir(workspace); + await initializeFixtureRepository(workspace, { unborn }); + const configPath = path.join(tempRoot, "backends.json"); + await writeFile(configPath, JSON.stringify({ + backends: { + fake: { + label: "Fake backend", + command: process.execPath, + buildArgs: [fakeBackendPath, ""], + resumeArgs: null, + experimental: false, + }, + }, + })); + const client = new McpClient(configPath, extraEnv); + await client.initialize(); + context.after(async () => { + await client.close(); + await rm(tempRoot, { recursive: true, force: true }); + }); + return { tempRoot, workspace, configPath, client }; +} + +async function initializeFixtureRepository(workspace, { unborn = false } = {}) { + await execFileAsync("git", ["init", "-b", "main"], { cwd: workspace }); + await execFileAsync("git", ["config", "user.name", "Bridge Test"], { cwd: workspace }); + await execFileAsync("git", ["config", "user.email", "bridge-test@example.invalid"], { cwd: workspace }); + if (!unborn) { + await writeFile(path.join(workspace, "baseline.txt"), "baseline\n"); + await execFileAsync("git", ["add", "baseline.txt"], { cwd: workspace }); + await execFileAsync("git", ["commit", "-m", "baseline"], { cwd: workspace }); + } +} + +function taskArguments(workspacePath, spec, extra = {}) { + return { + name: "delegate_task", + arguments: { + backend: "fake", + task: JSON.stringify(spec), + workspacePath, + ...extra, + }, + }; +} + +async function events(file) { + try { + return (await readFile(file, "utf8")).trim().split(/\r?\n/u).filter(Boolean).map((line) => JSON.parse(line)); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } +} + +async function waitFor(predicate, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + while (!await predicate()) { + if (Date.now() >= deadline) throw new Error("condition was not met before timeout"); + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + +async function makeTraceFixture(context) { + const root = await mkdtemp(path.join(os.tmpdir(), "cli-agent-trace-fd-test-")); + const tracePath = path.join(root, "git.trace"); + const handle = await open(tracePath, "wx+", 0o600); + const identity = await handle.stat({ bigint: true }); + let closed = false; + context.after(async () => { + if (!closed) await handle.close().catch(() => {}); + await rm(root, { recursive: true, force: true }); + }); + return { + root, + tracePath, + handle, + identity, + async close() { + if (closed) return; + closed = true; + await handle.close(); + }, + }; +} + +function testCancellation() { + const listeners = new Set(); + return { + cancelled: false, + subscribe(listener) { + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + cancel() { + if (this.cancelled) return; + this.cancelled = true; + for (const listener of [...listeners]) listener(); + }, + }; +} + +test("explicit backend configuration overrides fail closed atomically", async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), "cli-agent-explicit-config-test-")); + const workspace = path.join(root, "workspace"); + await mkdir(workspace); + await initializeFixtureRepository(workspace); + context.after(() => rm(root, { recursive: true, force: true })); + const cases = [ + { name: "empty-path", configPath: "" }, + { name: "missing-file", configPath: path.join(root, "missing.json") }, + { name: "malformed-json", content: "{" }, + { name: "invalid-utf8", content: Buffer.from([0xc3, 0x28]) }, + { name: "oversized", content: Buffer.alloc(1_000_001, 0x20) }, + { name: "missing-backends", content: "{}" }, + { name: "array-backends", content: JSON.stringify({ backends: [] }) }, + { name: "empty-backends", content: JSON.stringify({ backends: {} }) }, + { name: "invalid-entry", content: JSON.stringify({ + backends: { broken: { command: process.execPath, buildArgs: "" } }, + }) }, + ]; + for (const fixture of cases) { + const configPath = fixture.configPath ?? path.join(root, fixture.name + ".json"); + if (fixture.content !== undefined) await writeFile(configPath, fixture.content); + const client = new McpClient(configPath); + await client.initialize(); + try { + const listed = await client.request("tools/call", { + name: "list_backends", arguments: {}, + }); + assert.equal(listed.error?.code, -32603, fixture.name + ": " + JSON.stringify(listed)); + assert.match(listed.error.message, /explicit backend configuration/iu); + const delegated = await client.request("tools/call", { + name: "delegate_task", + arguments: { backend: "codex", task: "must not start", workspacePath: workspace }, + }); + assert.equal(delegated.error?.code, -32603, + fixture.name + ": " + JSON.stringify(delegated)); + assert.match(delegated.error.message, /explicit backend configuration/iu); + } finally { + await client.close(); + } + } +}); + +test("backend configuration helper control environment excludes inherited injection", async () => { + const env = backendConfigurationReaderEnvironment({ + SystemRoot: "C:\\Windows", + PATH: "trusted-path", + GH_TOKEN: "secret-token", + SSH_AUTH_SOCK: "secret-agent", + NODE_OPTIONS: "--require=attacker.cjs", + Node_Path: "attacker-modules", + LD_PRELOAD: "attacker.so", + DyLd_InSeRt_LiBrArIeS: "attacker.dylib", + GIT_DIR: "attacker.git", + Git_Config_Count: "1", + }); + assert.deepEqual(env, { + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8", + ...(process.platform === "win32" ? { + PATHEXT: ".COM;.EXE;.BAT;.CMD", + PATH: "", + PSModulePath: "", + HOMEDRIVE: "", + HOMEPATH: "", + LOGONSERVER: "", + SYSTEMDRIVE: "", + USERDOMAIN: "", + USERNAME: "", + USERPROFILE: "", + } : {}), + SystemRoot: "C:\\Windows", + }); + if (process.platform === "win32") { + const result = await runCommand(process.execPath, [ + "-e", "process.stdout.write(JSON.stringify(process.env))", + ], { + env: backendConfigurationControlEnvironment({ + ...process.env, + PATH: "C:\\attacker-path", + GH_TOKEN: "secret-token", + SSH_AUTH_SOCK: "secret-agent", + NODE_OPTIONS: "--require=attacker.cjs", + PSModulePath: "attacker-modules", + }), + exactEnvironment: env, + forwardExactEnvironment: true, + manageProcessTree: true, + timeoutMs: 5_000, + }); + assert.equal(result.treeTerminated, true, JSON.stringify(result)); + assert.equal(result.exitCode, 0, JSON.stringify(result)); + const actual = JSON.parse(result.stdout); + for (const forbidden of [ + "PATH", "PSMODULEPATH", "USERPROFILE", "USERNAME", "GH_TOKEN", "SSH_AUTH_SOCK", + "NODE_OPTIONS", "GIT_DIR", + ]) { + const actualName = Object.keys(actual).find((name) => name.toUpperCase() === forbidden); + assert.equal(actualName === undefined ? "" : actual[actualName], "", + forbidden + " leaked a non-empty value into the actual managed helper"); + } + } +}); + +test("backend configuration cleanup uncertainty outranks cancellation", async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), "cli-agent-config-cleanup-test-")); + const config = path.join(root, "backends.json"); + await writeFile(config, JSON.stringify({ + backends: { fixture: { command: process.execPath, buildArgs: [""] } }, + })); + context.after(() => rm(root, { recursive: true, force: true })); + const saved = { + nodeEnv: process.env.NODE_ENV, + override: process.env.CLI_AGENT_BRIDGE_BACKENDS, + stall: process.env.CLI_AGENT_BRIDGE_TEST_BACKEND_CONFIG_READ_STALL_FILE, + }; + process.env.NODE_ENV = "test"; + process.env.CLI_AGENT_BRIDGE_TEST_BACKEND_CONFIG_READ_STALL_FILE = path.join(root, "unused"); + context.after(() => { + if (saved.nodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = saved.nodeEnv; + if (saved.override === undefined) delete process.env.CLI_AGENT_BRIDGE_BACKENDS; + else process.env.CLI_AGENT_BRIDGE_BACKENDS = saved.override; + if (saved.stall === undefined) delete process.env.CLI_AGENT_BRIDGE_TEST_BACKEND_CONFIG_READ_STALL_FILE; + else process.env.CLI_AGENT_BRIDGE_TEST_BACKEND_CONFIG_READ_STALL_FILE = saved.stall; + }); + for (const explicitOverride of [true, false]) { + if (explicitOverride) process.env.CLI_AGENT_BRIDGE_BACKENDS = config; + else delete process.env.CLI_AGENT_BRIDGE_BACKENDS; + const cancel = testCancellation(); + await assert.rejects(loadBackends({ + cancel, + commandRunner: async () => { + cancel.cancel(); + return { + treeTerminated: false, + terminationError: "fixture cleanup uncertainty", + timedOut: false, + exitCode: 0, + stderr: "", + stdout: "{}", + stdoutTruncated: false, + }; + }, + }), /cleanup could not be confirmed.*fixture cleanup uncertainty/iu, + explicitOverride ? "explicit override" : "bundled configuration"); + } +}); + +test("an unset backend override still loads the bundled configuration", async (context) => { + const original = process.env.CLI_AGENT_BRIDGE_BACKENDS; + delete process.env.CLI_AGENT_BRIDGE_BACKENDS; + context.after(() => { + if (original === undefined) delete process.env.CLI_AGENT_BRIDGE_BACKENDS; + else process.env.CLI_AGENT_BRIDGE_BACKENDS = original; + }); + const backends = await loadBackends(); + assert.ok(backends.codex); + assert.deepEqual(backends.codex.buildArgs, ["exec", "--", ""]); +}); + +test("backend configuration reads obey cancellation, deadline, and shutdown", async (context) => { + const stallRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-config-read-stall-test-")); + const startedFile = path.join(stallRoot, "started.txt"); + context.after(() => rm(stallRoot, { recursive: true, force: true })); + const { tempRoot, workspace, client } = await makeHarness(context, { + extraEnv: { + CLI_AGENT_BRIDGE_TEST_BACKEND_CONFIG_READ_STALL_FILE: startedFile, + }, + }); + const eventFile = path.join(tempRoot, "config-read-events.jsonl"); + + const timed = await client.request("tools/call", taskArguments(workspace, { + name: "config-read-deadline", eventFile, + }, { timeoutMs: 5_000 }), 50_101); + assert.ok(timed.result, JSON.stringify({ timed, stderr: client.stderr })); + assert.equal(timed.result.structuredContent.timedOut, true, JSON.stringify(timed)); + assert.match(timed.result.structuredContent.error, /loading backend configuration/iu); + assert.deepEqual(await events(eventFile), [], "the backend must not start after a config timeout"); + + await rm(startedFile, { force: true }); + const listing = client.request("tools/call", { + name: "list_backends", arguments: {}, + }, 50_102); + await waitFor(async () => { + try { return (await readFile(startedFile, "utf8")).includes("started"); } catch { return false; } + }); + const cancelledAt = Date.now(); + client.notify("notifications/cancelled", { requestId: 50_102 }); + const cancelled = await listing; + assert.ok(Date.now() - cancelledAt < 1_500, "configuration cancellation must return promptly"); + assert.equal(cancelled.result, undefined, JSON.stringify(cancelled)); + assert.equal(cancelled.error?.code, -32800, JSON.stringify(cancelled)); + assert.match(cancelled.error?.message ?? "", /list_backends cancelled/iu); + + await rm(startedFile, { force: true }); + const pending = client.request("tools/call", { + name: "list_backends", arguments: {}, + }, 50_103); + void pending.catch(() => {}); + await waitFor(async () => { + try { return (await readFile(startedFile, "utf8")).includes("started"); } catch { return false; } + }); + const shutdownAt = Date.now(); + await client.disconnectInput(); + assert.ok(Date.now() - shutdownAt < 3_000, + "stdin shutdown must detach a stalled backend-configuration read"); +}); + +test("Windows config reader termination closes real named-pipe I/O", { + skip: process.platform !== "win32", +}, async (context) => { + const pipePath = "\\\\.\\pipe\\cli-agent-config-" + String(process.pid) + "-" + + String(Date.now()); + const sockets = new Set(); + const queued = []; + const waiters = []; + const pipeServer = createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + const waiter = waiters.shift(); + if (waiter) waiter(socket); + else queued.push(socket); + }); + await new Promise((resolve, reject) => { + pipeServer.once("error", reject); + pipeServer.listen(pipePath, resolve); + }); + const nextConnection = () => queued.length > 0 + ? Promise.resolve(queued.shift()) + : new Promise((resolve) => waiters.push(resolve)); + const waitForSocketClose = async (socket, label) => { + if (!socket.destroyed) { + let timer; + await Promise.race([ + new Promise((resolve) => socket.once("close", resolve)), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(label + " pipe socket stayed open")), 1_500); + }), + ]).finally(() => clearTimeout(timer)); + } + assert.equal(sockets.has(socket), false, label + " must close the helper's pipe handle"); + }; + context.after(() => { + for (const socket of sockets) socket.destroy(); + pipeServer.close(); + }); + + const cancelledClient = new McpClient(pipePath); + await cancelledClient.initialize(); + context.after(() => { if (cancelledClient.child.exitCode === null) cancelledClient.child.kill(); }); + const listing = cancelledClient.request("tools/call", { + name: "list_backends", arguments: {}, + }, 50_201); + const cancelledSocket = await nextConnection(); + const cancelledAt = Date.now(); + cancelledClient.notify("notifications/cancelled", { requestId: 50_201 }); + const cancelled = await listing; + assert.equal(cancelled.result, undefined, JSON.stringify(cancelled)); + assert.equal(cancelled.error?.code, -32800, JSON.stringify(cancelled)); + assert.ok(Date.now() - cancelledAt < 1_500, + "cancelling a named-pipe config read must terminate its managed helper"); + await waitForSocketClose(cancelledSocket, "cancelled read"); + await cancelledClient.close(); + + const deadlineClient = new McpClient(pipePath); + await deadlineClient.initialize(); + context.after(() => { if (deadlineClient.child.exitCode === null) deadlineClient.child.kill(); }); + const deadlineRequest = deadlineClient.request("tools/call", { + name: "delegate_task", + arguments: { + backend: "codex", + task: "must not start", + workspacePath: process.cwd(), + timeoutMs: 5_000, + }, + }, 50_202); + const deadlineSocket = await nextConnection(); + const deadlineResponse = await deadlineRequest; + assert.equal(deadlineResponse.result.structuredContent.timedOut, true, + JSON.stringify(deadlineResponse)); + await waitForSocketClose(deadlineSocket, "deadline read"); + await deadlineClient.close(); + + const shutdownClient = new McpClient(pipePath); + await shutdownClient.initialize(); + context.after(() => { if (shutdownClient.child.exitCode === null) shutdownClient.child.kill(); }); + const pending = shutdownClient.request("tools/call", { + name: "list_backends", arguments: {}, + }, 50_203); + void pending.catch(() => {}); + const shutdownSocket = await nextConnection(); + const shutdownAt = Date.now(); + await shutdownClient.disconnectInput(); + assert.ok(Date.now() - shutdownAt < 3_000, + "stdin shutdown must kill a helper blocked in real named-pipe readFile I/O"); + await waitForSocketClose(shutdownSocket, "shutdown read"); +}); + +test("explicit invalid delegation timeouts are rejected before side effects", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const eventFile = path.join(tempRoot, "invalid-timeout-events.jsonl"); + for (const timeoutMs of ["5000", 5_000.5, null, false, 4_999, 3_600_001]) { + const response = await client.request("tools/call", taskArguments(workspace, { + name: "invalid-timeout", eventFile, + }, { timeoutMs })); + assert.equal(response.error?.code, -32602, JSON.stringify(response)); + assert.equal( + response.error.message, + "timeoutMs must be an integer between 5000 and 3600000 milliseconds", + ); + } + assert.deepEqual(await events(eventFile), [], "an invalid timeout must not launch a worker"); +}); + +test("malformed delegation arguments return JSON-RPC invalid params", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const eventFile = path.join(tempRoot, "invalid-arguments-events.jsonl"); + const cases = [ + { task: "valid task", workspacePath: workspace }, + { backend: "missing-backend", task: "valid task", workspacePath: workspace }, + { backend: "fake", task: "", workspacePath: workspace }, + { backend: "fake", task: JSON.stringify({ eventFile }), workspacePath: "" }, + ]; + for (const arguments_ of cases) { + const response = await client.request("tools/call", { + name: "delegate_task", arguments: arguments_, + }); + assert.equal(response.error?.code, -32602, JSON.stringify(response)); + } + assert.deepEqual(await events(eventFile), [], "invalid arguments must not launch a worker"); +}); + +test("Git executable resolution obeys request cancellation, deadline, and shutdown", async (context) => { + const resolverRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-git-resolution-test-")); + const startedFile = path.join(resolverRoot, "started.txt"); + context.after(() => rm(resolverRoot, { recursive: true, force: true })); + const { tempRoot, workspace, client } = await makeHarness(context, { + extraEnv: { + NODE_ENV: "test", + CLI_AGENT_BRIDGE_TEST_GIT_RESOLUTION_DELAY_MS: "60000", + CLI_AGENT_BRIDGE_TEST_GIT_RESOLUTION_STARTED_FILE: startedFile, + }, + }); + + const cancelledRequest = client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }, 51_001); + await waitFor(async () => { + try { return (await readFile(startedFile, "utf8")).includes("started"); } catch { return false; } + }); + const cancelledAt = Date.now(); + client.notify("notifications/cancelled", { requestId: 51_001 }); + const cancelled = await cancelledRequest; + assert.equal(cancelled.result.structuredContent.cancelled, true, JSON.stringify(cancelled)); + assert.ok(Date.now() - cancelledAt < 1_500, "Git resolution cancellation must detach promptly"); + + const eventFile = path.join(tempRoot, "git-resolution-events.jsonl"); + const timed = await client.request("tools/call", taskArguments(workspace, { + name: "git-resolution-deadline", eventFile, + }, { timeoutMs: 5_000 }), 51_002); + assert.equal(timed.result.structuredContent.timedOut, true, JSON.stringify(timed)); + assert.match(timed.result.structuredContent.error, /identifying the Git worktree/iu); + assert.deepEqual(await events(eventFile), [], "the backend must not start after resolution timeout"); + assert.equal((await readFile(startedFile, "utf8")).trim().split(/\r?\n/u).length, 1, + "cancelled and timed-out waiters must share one core Git lookup"); + + const pending = client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }, 51_003); + void pending.catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 100)); + const shutdownAt = Date.now(); + await client.disconnectInput(); + assert.ok(Date.now() - shutdownAt < 3_000, + "stdin shutdown must detach the active Git-resolution waiter"); +}); + +test("list_backends rejects a successful probe whose tree cleanup is unconfirmed", () => { + const entry = backendEntryFromProbe("fixture", { + label: "Fixture", command: "fixture", buildArgs: [""], resumeArgs: null, + }, { + exitCode: 0, + treeTerminated: false, + terminationError: "fixture version-probe descendant remains", + errorMessage: "", + stdout: "fixture 1.2.3\n", + }); + assert.equal(entry.available, false); + assert.equal(entry.version, null); + assert.equal(entry.error, "fixture version-probe descendant remains"); +}); + +test("list_backends preserves version-probe timeout, exit, stderr, and spawn failures", () => { + const spec = { + label: "Fixture", command: "fixture", buildArgs: [""], resumeArgs: null, + }; + const timed = backendEntryFromProbe("fixture", spec, { + exitCode: 0, + timedOut: true, + treeTerminated: true, + stderr: "timeout diagnostic", + stdout: "fixture 1.2.3\n", + }); + assert.equal(timed.available, false); + assert.equal(timed.version, null); + assert.match(timed.error, /timed out after 15000 ms.*timeout diagnostic/iu); + + const nonzero = backendEntryFromProbe("fixture", spec, { + exitCode: 37, + timedOut: false, + treeTerminated: true, + stderr: "license unavailable", + errorMessage: "", + }); + assert.match(nonzero.error, /exited with code 37.*license unavailable/iu); + + const spawnFailure = backendEntryFromProbe("fixture", spec, { + exitCode: null, + timedOut: false, + treeTerminated: true, + stderr: "", + errorMessage: "spawn fixture ENOENT", + }); + assert.equal(spawnFailure.error, "spawn fixture ENOENT"); +}); + +test("cancellation requires a fresh workspace_status to reveal earlier edits", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const eventFile = path.join(tempRoot, "cancelled-edit-events.jsonl"); + const changedFile = "cancelled-edit.txt"; + const pending = client.request("tools/call", taskArguments(workspace, { + name: "cancelled-edit", eventFile, writeBeforeDelay: true, writeFile: changedFile, + }), 51_004); + await waitFor(async () => (await events(eventFile)).some((item) => item.event === "written")); + client.notify("notifications/cancelled", { requestId: 51_004 }); + const cancelled = await pending; + const cancelledOut = cancelled.result.structuredContent; + assert.equal(cancelledOut.cancelled, true, JSON.stringify(cancelledOut)); + assert.equal(cancelledOut.treeTerminated, true, JSON.stringify(cancelledOut)); + assert.equal(cancelledOut.git, null, + "the cancelled response must not be treated as evidence that no edits occurred"); + + const status = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + const statusOut = status.result.structuredContent; + assert.equal(statusOut.ok, true, JSON.stringify(statusOut)); + assert.ok(statusOut.git.changedFiles.includes(changedFile), JSON.stringify(statusOut.git)); +}); + +test("provenance setup obeys cancellation and deadline before worker launch", async (context) => { + const setupRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-trace-setup-test-")); + const startedFile = path.join(setupRoot, "started.txt"); + context.after(() => rm(setupRoot, { recursive: true, force: true })); + const { tempRoot, workspace, client } = await makeHarness(context, { + extraEnv: { + NODE_ENV: "test", + CLI_AGENT_BRIDGE_TEST_TRACE_CREATION_DELAY_MS: "60000", + CLI_AGENT_BRIDGE_TEST_TRACE_CREATION_STARTED_FILE: startedFile, + }, + }); + const eventFile = path.join(tempRoot, "trace-setup-events.jsonl"); + const cancelledRequest = client.request("tools/call", taskArguments(workspace, { + name: "cancelled-trace-setup", eventFile, + }), 51_005); + await waitFor(async () => { + try { return (await readFile(startedFile, "utf8")).trim().length > 0; } catch { return false; } + }, 20_000); + client.notify("notifications/cancelled", { requestId: 51_005 }); + const cancelled = await cancelledRequest; + assert.equal(cancelled.result.structuredContent.cancelled, true, JSON.stringify(cancelled)); + + const timed = await client.request("tools/call", taskArguments(workspace, { + name: "expired-trace-setup", eventFile, + }, { timeoutMs: 5_000 }), 51_006); + assert.equal(timed.result.structuredContent.timedOut, true, JSON.stringify(timed)); + assert.match(timed.result.structuredContent.error, /preparing Git provenance/iu); + assert.deepEqual(await events(eventFile), [], "trace setup interruption must precede worker launch"); + for (const traceRoot of (await readFile(startedFile, "utf8")).trim().split(/\r?\n/u)) { + await assert.rejects(access(traceRoot), /ENOENT/u, + "interrupted provenance setup must close its handle and remove its private root"); + } +}); + +test("shutdown waits for a late provenance-setup cleanup", async (context) => { + const setupRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-trace-shutdown-test-")); + const startedFile = path.join(setupRoot, "started.txt"); + const releaseFile = path.join(setupRoot, "release.txt"); + context.after(() => rm(setupRoot, { recursive: true, force: true })); + const { tempRoot, workspace, client } = await makeHarness(context, { + extraEnv: { + NODE_ENV: "test", + CLI_AGENT_BRIDGE_TEST_TRACE_PENDING_STEP_RELEASE_FILE: releaseFile, + CLI_AGENT_BRIDGE_TEST_TRACE_CREATION_STARTED_FILE: startedFile, + }, + }); + const eventFile = path.join(tempRoot, "trace-shutdown-events.jsonl"); + const pending = client.request("tools/call", taskArguments(workspace, { + name: "pending-trace-setup", eventFile, + }), 51_007); + await waitFor(async () => { + try { return (await readFile(startedFile, "utf8")).trim().length > 0; } catch { return false; } + }, 20_000); + const traceRoot = (await readFile(startedFile, "utf8")).trim(); + client.notify("notifications/cancelled", { requestId: 51_007 }); + const response = await pending; + assert.equal(response.result.structuredContent.cancelled, true, JSON.stringify(response)); + await writeFile(releaseFile, "release\n"); + await client.disconnectInput(); + await assert.rejects(access(traceRoot), /ENOENT/u, + "shutdown must await the registered late cleanup before exiting"); + assert.deepEqual(await events(eventFile), []); +}); + +test("Codex templates delimit option-looking task text", async () => { + const backends = JSON.parse(await readFile(path.join(pluginRoot, "backends.json"), "utf8")).backends; + assert.deepEqual(backends.codex.buildArgs, ["exec", "--", ""]); + assert.deepEqual(backends.codex.resumeArgs, ["exec", "resume", "", "--", ""]); + const source = await readFile(serverPath, "utf8"); + assert.match(source, /buildArgs: \["exec", "--", ""\]/u); + assert.match(source, /resumeArgs: \["exec", "resume", "", "--", ""\]/u); +}); + +test("unsupported production platforms fail before config, workspace, Git, or backend access", async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), "cli-agent-unsupported-platform-test-")); + const workspace = path.join(root, "workspace"); + const missingWorkspace = path.join(root, "workspace-must-not-be-read"); + const marker = path.join(workspace, "backend-started.txt"); + const gitResolutionMarker = path.join(root, "git-resolution-started.txt"); + const backendResolutionMarker = path.join(root, "backend-resolution-started.txt"); + const sentinel = path.join(workspace, "sentinel.txt"); + const configPath = path.join(root, "backends.json"); + await mkdir(workspace); + await writeFile(sentinel, "unchanged\n"); + context.after(async () => { + await rm(root, { recursive: true, force: true }); + }); + + for (const platform of ["linux", "darwin", "freebsd"]) { + // initialize does not load backend configuration. Removing the explicit + // file before tools/call proves the platform gate wins before config I/O. + await writeFile(configPath, JSON.stringify({ + backends: { + fake: { + label: "Fake backend", + command: process.execPath, + buildArgs: ["-e", `require('node:fs').writeFileSync(${JSON.stringify(marker)},'started')`], + resumeArgs: null, + }, + }, + })); + const client = new McpClient(configPath, { + NODE_ENV: "test", + CLI_AGENT_BRIDGE_TEST_PLATFORM: platform, + CLI_AGENT_BRIDGE_TEST_PROCESS_TREE_MODE: "0", + CLI_AGENT_BRIDGE_TEST_GIT_RESOLUTION_DELAY_MS: "1", + CLI_AGENT_BRIDGE_TEST_GIT_RESOLUTION_STARTED_FILE: gitResolutionMarker, + CLI_AGENT_BRIDGE_TEST_COMMAND_RESOLUTION_DELAY_MS: "1", + CLI_AGENT_BRIDGE_TEST_COMMAND_RESOLUTION_STARTED_FILE: backendResolutionMarker, + }); + await client.initialize(); + await rm(configPath, { force: true }); + try { + const listed = await client.request("tools/call", { + name: "list_backends", arguments: {}, + }); + const backend = listed.result.structuredContent.backends[0]; + assert.equal(backend.name, "unsupported-platform"); + assert.equal(backend.available, false); + assert.equal(backend.version, null); + assert.match(backend.error, new RegExp("unsupported on " + platform, "iu")); + + const delegated = await client.request( + "tools/call", taskArguments(missingWorkspace, { name: "run" }), + ); + const out = delegated.result.structuredContent; + assert.equal(out.ok, false); + assert.match(out.error, new RegExp("unsupported on " + platform, "iu")); + const status = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: missingWorkspace }, + }); + assert.equal(status.result.structuredContent.ok, false); + assert.match(status.result.structuredContent.error, + new RegExp("unsupported on " + platform, "iu")); + } finally { + await client.close(); + } + } + assert.deepEqual(await readdir(workspace), ["sentinel.txt"]); + assert.equal(await readFile(sentinel, "utf8"), "unchanged\n"); + for (const forbiddenMarker of [marker, gitResolutionMarker, backendResolutionMarker]) { + await assert.rejects(access(forbiddenMarker), /ENOENT/u); + } +}); + +test("a bare backend command never resolves from the workspace cwd", { + skip: process.platform !== "win32", +}, async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), "cli-agent-backend-shadow-test-")); + const workspace = path.join(root, "workspace"); + const marker = path.join(workspace, "shadow-started.txt"); + const configPath = path.join(root, "backends.json"); + await mkdir(workspace); + await copyFile(process.execPath, path.join(workspace, "workspace-shadow-backend.exe")); + await writeFile(configPath, JSON.stringify({ + backends: { + shadow: { + label: "Shadow fixture", + command: "workspace-shadow-backend", + buildArgs: ["-e", `require('node:fs').writeFileSync(${JSON.stringify(marker)},'started')`], + resumeArgs: null, + }, + }, + })); + const client = new McpClient(configPath); + await client.initialize(); + context.after(async () => { + await client.close(); + await rm(root, { recursive: true, force: true }); + }); + const response = await client.request("tools/call", { + name: "delegate_task", + arguments: { backend: "shadow", task: "run", workspacePath: workspace }, + }); + const out = response.result.structuredContent; + assert.equal(out.ok, false); + assert.match(out.error, /command was not found/iu); + await assert.rejects(access(marker), /ENOENT/u); +}); + +test("delegate_task rejects resume for a backend without resume support", async (context) => { + const { workspace, client } = await makeHarness(context); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "must-not-start", writeFile: "unsupported-resume.txt", + }, { resumeSessionId: "session-123" })); + const out = response.result.structuredContent; + assert.equal(out.ok, false); + assert.match(out.error, /does not support resuming/iu); + await assert.rejects(access(path.join(workspace, "unsupported-resume.txt")), /ENOENT/u); +}); + +test("dirty checks include untracked files even when Git config hides them", async (context) => { + const { workspace, client } = await makeHarness(context); + await execFileAsync("git", ["config", "status.showUntrackedFiles", "no"], { cwd: workspace }); + await writeFile(path.join(workspace, "hidden-untracked.txt"), "pre-existing\n"); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "must-not-start", writeFile: "worker-output.txt", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, false); + assert.match(out.error, /working tree is dirty/iu); + assert.ok(out.gitBefore.changedFiles.includes("hidden-untracked.txt")); + await assert.rejects(access(path.join(workspace, "worker-output.txt")), /ENOENT/u); +}); + +test("porcelain status preserves the unstaged first-column space", async (context) => { + const { workspace, client } = await makeHarness(context); + await writeFile(path.join(workspace, "baseline.txt"), "unstaged change\n"); + const response = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.match(response.result.structuredContent.git.statusShort, /^ M baseline\.txt$/u); +}); + +test("a staged-only diff stat keeps its staged source label", async (context) => { + const { workspace, client } = await makeHarness(context); + await writeFile(path.join(workspace, "staged-only.txt"), "staged\n"); + await execFileAsync("git", ["add", "staged-only.txt"], { cwd: workspace }); + const { stdout: unstaged } = await execFileAsync("git", ["diff", "--stat"], { cwd: workspace }); + assert.equal(unstaged, "", "the fixture must contain no unstaged diff"); + + const response = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out)); + assert.match(out.git.diffStat, /staged-only\.txt/u); + assert.ok(out.git.diffStat.split(/\r?\n/u).every((line) => line.startsWith("staged: ")), + out.git.diffStat); +}); + +test("the private lock store inherits the repository sharing mode", async (context) => { + const { workspace, client } = await makeHarness(context); + await execFileAsync("git", ["config", "core.sharedRepository", "group"], { cwd: workspace }); + const response = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(response.result.structuredContent.ok, true); + const lockStore = await coordinationLockStore(workspace); + const { stdout } = await execFileAsync( + "git", ["--git-dir", lockStore, "config", "--get", "core.sharedRepository"], + ); + assert.ok(["1", "group"].includes(stdout.trim()), stdout); + if (process.platform !== "win32") { + const storeMode = (await stat(lockStore)).mode & 0o777; + assert.equal(storeMode & 0o070, 0o070, "the repository group must be able to traverse its lock store"); + const identityRefMode = (await stat(path.join( + lockStore, "refs", "cli-agent-bridge", "repository-id", + ))).mode & 0o777; + assert.equal(identityRefMode & 0o060, 0o060, + "the repository group must be able to read/write its identity ref"); + const legacyIdentityMode = (await stat(path.join( + lockStore, "cli-agent-bridge-repository-id", + ))).mode & 0o777; + assert.equal(legacyIdentityMode & 0o060, 0o060, + "rolling-upgrade readers in the repository group must be able to read the identity anchor"); + const quarantineRoot = path.join(lockStore, "cli-agent-bridge-quarantines"); + const quarantineRootMode = (await stat(quarantineRoot)).mode & 0o2777; + const quarantineMode = quarantineRootMode & 0o777; + assert.equal(quarantineMode & 0o070, 0o070, + "the repository group must be able to publish and recover quarantine records"); + assert.equal(quarantineRootMode & 0o2000, 0o2000, + "shared quarantine records must inherit the repository group"); + assert.deepEqual( + (await readdir(lockStore)).filter((name) => name.startsWith(".cli-agent-bridge-quarantines-")), + [], + "atomic root publication must not leave a candidate directory behind", + ); + const quarantine = await markWorkspaceQuarantined( + quarantineRoot, "shared-quarantine-mode", { terminationError: "fixture" }, + ); + context.after(() => rm(quarantine.quarantinePath, { recursive: true, force: true })); + const markerMode = (await stat(quarantine.quarantinePath)).mode & 0o2777; + const recordMode = (await stat(path.join( + quarantine.quarantinePath, "record.json", + ))).mode & 0o777; + assert.equal(markerMode & 0o2070, 0o2070, + "other repository-group users must be able to traverse and rename the marker"); + assert.equal(recordMode & 0o060, 0o060, + "other repository-group users must be able to read the incident-bound recovery id"); + } +}); + +test("every extant foreign lease ref remains visible to attribution", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const initialized = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(initialized.result.structuredContent.ok, true, JSON.stringify(initialized)); + const store = await coordinationLockStore(workspace); + const otherRef = workspaceLockRef("fixture-stale-active-lease"); + const writeOwner = async (workerState) => { + const ownerPath = path.join(tempRoot, "stale-" + workerState + "-owner.json"); + await writeFile(ownerPath, JSON.stringify({ + version: 1, + token: "fixture-stale-" + workerState, + hostIdentity: "fixture:other-bridge", + ownerPid: 4242, + ownerIdentity: "fixture-start", + workerState, + workerPid: workerState === "idle" ? null : 4343, + acquiredAt: Date.now() - 180_000, + heartbeatAt: Date.now() - 120_000, + })); + const { stdout } = await execFileAsync("git", ["hash-object", "-w", ownerPath], { cwd: store }); + await execFileAsync("git", ["update-ref", otherRef, stdout.trim()], { cwd: store }); + }; + context.after(async () => { + try { await execFileAsync("git", ["update-ref", "-d", otherRef], { cwd: store }); } catch { /* already gone */ } + }); + + await writeOwner("running"); + const active = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(active.result.structuredContent.git.concurrentDelegations, 1, + "a current running ref remains active even when its write timestamp is old"); + + await writeOwner("idle"); + const idle = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(idle.result.structuredContent.git.concurrentDelegations, 1, + "an idle foreign lease may still be between worker cleanup and its durable activity marker"); +}); + +test("history ref changes disclose overlap without comparing host clocks", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const initialized = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(initialized.result.structuredContent.ok, true, JSON.stringify(initialized)); + const store = await coordinationLockStore(workspace); + const historyRef = workspaceLockRef("fixture-clock-skewed-worktree") + ".history"; + context.after(async () => { + try { await execFileAsync("git", ["update-ref", "-d", historyRef], { cwd: store }); } catch { /* gone */ } + }); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "clock-skewed-overlap", + eventFile: path.join(tempRoot, "clock-skewed-overlap.jsonl"), + writeConcurrencyHistory: true, + historyStore: store, + historyRef, + // The old Date.now comparison rejected this completed run as ancient even + // though its marker was created between the two repository snapshots. + acquiredAt: 1, + endedAt: 2, + writeFile: "clock-skewed-overlap.txt", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out)); + assert.equal(out.repositoryConcurrency, true, JSON.stringify(out)); + assert.equal(out.git.concurrentDelegations, 1, JSON.stringify(out.git)); +}); + +test("an unavailable activity marker prevents backend launch", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const initialized = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(initialized.result.structuredContent.ok, true, JSON.stringify(initialized)); + const commonDir = await canonicalGitCommonDirectory(workspace); + const store = await coordinationLockStore(workspace); + const lockRef = workspaceLockRef(await repositoryKey(commonDir)); + const historyLock = path.join(store, lockRef + ".history.lock"); + await mkdir(path.dirname(historyLock), { recursive: true }); + await writeFile(historyLock, "blocked\n"); + context.after(() => rm(historyLock, { force: true })); + const eventFile = path.join(tempRoot, "activity-marker-failure.jsonl"); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "must-not-start-without-activity-marker", eventFile, + })); + assert.match(response.error?.message ?? "", /workspace activity marker/iu, JSON.stringify(response)); + assert.deepEqual(await events(eventFile), [], "the backend must not start before marker durability"); + const { stdout: ownerRef } = await execFileAsync( + "git", ["rev-parse", "--verify", "--quiet", lockRef], { cwd: store }, + ).catch((error) => ({ stdout: error.stdout ?? "" })); + assert.equal(ownerRef.trim(), "", "failed marker publication must compensate its owner ref"); +}); + +test("concurrent first requests publish one valid repository identity", async (context) => { + const { workspace, configPath, client } = await makeHarness(context); + const secondClient = new McpClient(configPath); + try { + await secondClient.initialize(); + const request = { + name: "workspace_status", arguments: { workspacePath: workspace }, + }; + const [first, second] = await Promise.all([ + client.request("tools/call", request, 831), + secondClient.request("tools/call", request, 832), + ]); + assert.equal(first.result.structuredContent.ok, true, JSON.stringify(first)); + assert.equal(second.result.structuredContent.ok, true, JSON.stringify(second)); + const commonDir = await canonicalGitCommonDirectory(workspace); + const store = await coordinationLockStore(workspace); + const repositoryId = await repositoryIdFromStore(store); + assert.match(repositoryId, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u); + assert.equal( + (await readFile(path.join(store, "cli-agent-bridge-repository-id"), "utf8")).trim(), + repositoryId, + "a file-only older bridge must derive the same repository lock key", + ); + assert.deepEqual( + (await readdir(commonDir)).filter((name) => name.startsWith(".cli-agent-bridge-lock-store-")), + [], + "first-use initialization must not leave losing candidate stores behind", + ); + } finally { + await secondClient.close(); + } +}); + +test("repository identity sources disagree only by failing closed", async (context) => { + const { workspace, client } = await makeHarness(context); + const initialized = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(initialized.result.structuredContent.ok, true, JSON.stringify(initialized)); + const store = await coordinationLockStore(workspace); + const refIdentity = await repositoryIdFromStore(store); + assert.equal( + (await readFile(path.join(store, "cli-agent-bridge-repository-id"), "utf8")).trim(), + refIdentity, + ); + await writeFile( + path.join(store, "cli-agent-bridge-repository-id"), + "22222222-2222-4222-8222-222222222222\n", + ); + const conflicted = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.match(conflicted.error?.message ?? "", /repository identity sources conflict/iu); +}); + +test("a legacy repository identity file is migrated into the CAS ref", async (context) => { + const { workspace, client } = await makeHarness(context); + const initialized = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(initialized.result.structuredContent.ok, true, JSON.stringify(initialized)); + const store = await coordinationLockStore(workspace); + await execFileAsync("git", ["update-ref", "-d", "refs/cli-agent-bridge/repository-id"], { + cwd: store, + }); + const legacyId = "11111111-1111-4111-8111-111111111111"; + await writeFile(path.join(store, "cli-agent-bridge-repository-id"), legacyId + "\n"); + + const migrated = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(migrated.result.structuredContent.ok, true, JSON.stringify(migrated)); + assert.equal(await repositoryIdFromStore(store), legacyId); +}); + +test("a repository recreated at the same path gets a new logical lock identity", { + skip: process.platform !== "linux" ? "Linux persistent repository identity fixture" : false, +}, async (context) => { + const { workspace, client } = await makeHarness(context); + const initial = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(initial.result.structuredContent.ok, true, JSON.stringify(initial)); + const firstCommonDir = await canonicalGitCommonDirectory(workspace); + const firstId = await repositoryIdFromStore(path.join( + firstCommonDir, "cli-agent-bridge-lock-store.git", + )); + const firstState = await repositoryStatePaths(firstCommonDir); + await mkdir(firstState.root, { recursive: true }); + await writeFile(firstState.quarantinePath, JSON.stringify({ terminationError: "old repository" })); + context.after(() => rm(firstState.quarantinePath, { force: true })); + + await rm(workspace, { recursive: true, force: true }); + await mkdir(workspace); + await initializeFixtureRepository(workspace); + const recreated = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(recreated.result.structuredContent.ok, true, JSON.stringify(recreated)); + const secondCommonDir = await canonicalGitCommonDirectory(workspace); + const secondId = await repositoryIdFromStore(path.join( + secondCommonDir, "cli-agent-bridge-lock-store.git", + )); + assert.notEqual(secondId, firstId); +}); + +test("Git commands ignore an executable shadow in the workspace cwd", { + skip: process.platform !== "win32", +}, async (context) => { + const { workspace, client } = await makeHarness(context); + await copyFile(process.execPath, path.join(workspace, "git.exe")); + const response = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out)); + assert.ok(out.git.changedFiles.includes("git.exe")); +}); + +test("Git snapshot commands disable untrusted fsmonitor hooks", { + skip: process.platform !== "linux" ? "POSIX executable-hook fixture" : false, +}, async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const hook = path.join(tempRoot, "fsmonitor-hook.sh"); + const ready = path.join(tempRoot, "fsmonitor-ready.txt"); + const survivor = path.join(tempRoot, "fsmonitor-descendant-survived.txt"); + const childCode = [ + "const fs=require('node:fs')", + "setTimeout(()=>fs.writeFileSync(process.argv[1],'survived\\n'),1200)", + ].join(";"); + await writeFile(hook, [ + "#!/bin/sh", + "printf invoked > " + JSON.stringify(ready), + "setsid " + JSON.stringify(process.execPath) + " -e " + JSON.stringify(childCode) + + " " + JSON.stringify(survivor) + " >/dev/null 2>&1 &", + "printf 'fixture-token\\0'", + "", + ].join("\n")); + await chmod(hook, 0o755); + await execFileAsync("git", ["config", "core.fsmonitor", hook], { cwd: workspace }); + await execFileAsync("git", ["config", "core.fsmonitorHookVersion", "2"], { cwd: workspace }); + + const response = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(response.result.structuredContent.ok, true, + JSON.stringify(response.result.structuredContent)); + await assert.rejects(access(ready), /ENOENT/u, + "snapshot reads must override repository fsmonitor configuration"); + await assert.rejects(access(survivor), /ENOENT/u, + "an untrusted fsmonitor hook must never start a descendant"); +}); + +test("Git snapshot clean filters remain process-contained", { + skip: process.platform !== "linux" ? "POSIX executable-filter fixture" : false, +}, async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const filter = path.join(tempRoot, "clean-filter.sh"); + const ready = path.join(tempRoot, "clean-filter-ready.txt"); + const survivor = path.join(tempRoot, "clean-filter-descendant-survived.txt"); + const childCode = [ + "const fs=require('node:fs')", + "setTimeout(()=>fs.writeFileSync(process.argv[1],'survived\\n'),1200)", + ].join(";"); + await writeFile(filter, [ + "#!/bin/sh", + "printf invoked > " + JSON.stringify(ready), + "setsid " + JSON.stringify(process.execPath) + " -e " + JSON.stringify(childCode) + + " " + JSON.stringify(survivor) + " >/dev/null 2>&1 &", + "cat", + "", + ].join("\n")); + await chmod(filter, 0o755); + await writeFile(path.join(workspace, ".gitattributes"), "baseline.txt filter=audit\n"); + await execFileAsync("git", ["config", "filter.audit.clean", JSON.stringify(filter)], { cwd: workspace }); + await writeFile(path.join(workspace, "baseline.txt"), "changed\n"); + + const response = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + const out = response.result.structuredContent; + if (!out.ok) { + assert.match(out.error, /repository helper process tree could not be confirmed/iu); + assert.ok(out.quarantinePath, JSON.stringify(out)); + context.after(() => rm(out.quarantinePath, { recursive: true, force: true })); + } + assert.equal(await readFile(ready, "utf8"), "invoked", + "the fixture must prove that Git executed the configured clean filter"); + await new Promise((resolve) => setTimeout(resolve, 1_400)); + await assert.rejects(access(survivor), /ENOENT/u, + "a detached clean-filter descendant must not outlive the snapshot command"); +}); + +test("dirty checks override submodule ignore configuration", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const source = path.join(tempRoot, "submodule-source"); + await mkdir(source); + await execFileAsync("git", ["init", "-b", "main"], { cwd: source }); + await execFileAsync("git", ["config", "user.name", "Bridge Test"], { cwd: source }); + await execFileAsync("git", ["config", "user.email", "bridge-test@example.invalid"], { cwd: source }); + await writeFile(path.join(source, "tracked.txt"), "baseline\n"); + await execFileAsync("git", ["add", "tracked.txt"], { cwd: source }); + await execFileAsync("git", ["commit", "-m", "submodule baseline"], { cwd: source }); + await execFileAsync("git", ["-c", "protocol.file.allow=always", "submodule", "add", source, "nested-submodule"], { + cwd: workspace, + }); + await execFileAsync("git", ["commit", "-am", "add submodule"], { cwd: workspace }); + await execFileAsync("git", ["config", "submodule.nested-submodule.ignore", "all"], { cwd: workspace }); + await writeFile(path.join(workspace, "nested-submodule", "tracked.txt"), "pre-existing edit\n"); + + const response = await client.request("tools/call", taskArguments(workspace, { + name: "must-not-start", writeFile: "submodule-bypass.txt", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, false); + assert.match(out.error, /working tree is dirty/iu); + assert.match(out.gitBefore.statusShort, /nested-submodule/u); + assert.ok(out.gitBefore.changedFiles.includes("nested-submodule")); + await assert.rejects(access(path.join(workspace, "submodule-bypass.txt")), /ENOENT/u); +}); + +test("canonical Git worktree locking serializes root and symlink paths", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const alias = path.join(tempRoot, "workspace-alias"); + await symlink(workspace, alias, process.platform === "win32" ? "junction" : "dir"); + const eventFile = path.join(tempRoot, "events.jsonl"); + + const first = client.request("tools/call", taskArguments(workspace, { + name: "first", eventFile, delayMs: 500, writeFile: "first.txt", + })); + await waitFor(async () => (await events(eventFile)).some((item) => item.name === "first" && item.event === "start")); + const second = client.request("tools/call", taskArguments(alias, { + name: "second", eventFile, delayMs: 10, writeFile: "second.txt", + }, { allowDirty: true })); + + const [firstResponse, secondResponse] = await Promise.all([first, second]); + assert.equal(firstResponse.result.structuredContent.ok, true); + assert.equal(secondResponse.result.structuredContent.ok, true); + assert.deepEqual((await events(eventFile)).map((item) => item.event + ":" + item.name), [ + "start:first", "end:first", "start:second", "end:second", + ]); + assert.equal(firstResponse.result.structuredContent.worktreeRoot, secondResponse.result.structuredContent.worktreeRoot); +}); + +test("a retargeted workspace symlink cannot redirect a queued worker", async (context) => { + if (process.platform === "win32") return; // creating/retargeting symlinks is privilege-dependent + const { tempRoot, workspace, client } = await makeHarness(context); + const other = path.join(tempRoot, "other-workspace"); + await mkdir(other); + await execFileAsync("git", ["init", "-b", "main"], { cwd: other }); + await execFileAsync("git", ["config", "user.name", "Bridge Test"], { cwd: other }); + await execFileAsync("git", ["config", "user.email", "bridge-test@example.invalid"], { cwd: other }); + await writeFile(path.join(other, "baseline.txt"), "other baseline\n"); + await execFileAsync("git", ["add", "baseline.txt"], { cwd: other }); + await execFileAsync("git", ["commit", "-m", "other baseline"], { cwd: other }); + const alias = path.join(tempRoot, "retargetable-workspace"); + await symlink(workspace, alias, "dir"); + const eventFile = path.join(tempRoot, "retarget-events.jsonl"); + const holder = client.request("tools/call", taskArguments(workspace, { + name: "holder", eventFile, delayMs: 900, writeFile: "holder.txt", + })); + await waitFor(async () => (await events(eventFile)).some((item) => item.name === "holder" && item.event === "start")); + const queued = client.request("tools/call", taskArguments(alias, { + name: "queued", eventFile, writeFile: "queued.txt", + }, { allowDirty: true })); + await new Promise((resolve) => setTimeout(resolve, 200)); + await unlink(alias); + await symlink(other, alias, "dir"); + + assert.equal((await holder).result.structuredContent.ok, true); + assert.equal((await queued).result.structuredContent.ok, true); + await access(path.join(workspace, "queued.txt")); + await assert.rejects(access(path.join(other, "queued.txt")), /ENOENT/u); +}); + +test("relative backend commands resolve from their configuration directory", async (context) => { + if (process.platform === "win32") return; // executable symlink setup is POSIX-specific + const { tempRoot, workspace } = await makeHarness(context); + const configDirectory = path.join(tempRoot, "relative-config"); + await mkdir(configDirectory); + const nodeAlias = path.join(configDirectory, "node-wrapper"); + await symlink(process.execPath, nodeAlias, "file"); + const configPath = path.join(configDirectory, "backends.json"); + await writeFile(configPath, JSON.stringify({ backends: { relative: { + label: "Relative fixture", command: "./node-wrapper", + buildArgs: [fakeBackendPath, ""], resumeArgs: null, experimental: false, + } } })); + const relativeClient = new McpClient(configPath); + context.after(() => relativeClient.close()); + await relativeClient.initialize(); + const listed = await relativeClient.request("tools/call", { name: "list_backends", arguments: {} }); + assert.equal(listed.result.structuredContent.backends[0].available, true); + const delegated = await relativeClient.request("tools/call", { + name: "delegate_task", + arguments: { + backend: "relative", task: JSON.stringify({ name: "relative", writeFile: "relative.txt" }), + workspacePath: workspace, + }, + }); + assert.equal(delegated.result.structuredContent.ok, true, delegated.result.structuredContent.error); + await access(path.join(workspace, "relative.txt")); +}); + +test("canonical worktree locking serializes independent server processes", async (context) => { + const { tempRoot, workspace, configPath, client } = await makeHarness(context); + const secondClient = new McpClient(configPath); + try { + await secondClient.initialize(); + const eventFile = path.join(tempRoot, "cross-process-events.jsonl"); + const first = client.request("tools/call", taskArguments(workspace, { + name: "first-server", eventFile, delayMs: 800, writeFile: "first-server.txt", + })); + await waitFor(async () => (await events(eventFile)).some( + (item) => item.name === "first-server" && item.event === "start", + )); + const second = secondClient.request("tools/call", taskArguments(workspace, { + name: "second-server", eventFile, delayMs: 10, writeFile: "second-server.txt", + }, { allowDirty: true })); + + const [firstResponse, secondResponse] = await Promise.all([first, second]); + assert.equal(firstResponse.result.structuredContent.ok, true); + assert.equal(secondResponse.result.structuredContent.ok, true); + assert.deepEqual((await events(eventFile)).map((item) => item.event + ":" + item.name), [ + "start:first-server", "end:first-server", "start:second-server", "end:second-server", + ]); + } finally { + await secondClient.close(); + } +}); + +test("a cross-process lock waiter can be cancelled without starting its backend", async (context) => { + const { tempRoot, workspace, configPath, client } = await makeHarness(context); + const secondClient = new McpClient(configPath); + try { + await secondClient.initialize(); + const eventFile = path.join(tempRoot, "cross-process-cancel-events.jsonl"); + const holder = client.request("tools/call", taskArguments(workspace, { + name: "cancel-holder", eventFile, delayMs: 3_000, + }), 121); + await waitFor(async () => (await events(eventFile)).some( + (item) => item.name === "cancel-holder" && item.event === "start", + )); + const waiter = secondClient.request("tools/call", taskArguments(workspace, { + name: "cancelled-waiter", eventFile, + }), 122); + await new Promise((resolve) => setTimeout(resolve, 150)); + const cancelledAt = Date.now(); + secondClient.notify("notifications/cancelled", { requestId: 122 }); + + const waiterResponse = await waiter; + assert.ok(Date.now() - cancelledAt < 1_500, "cross-process lock cancellation must settle promptly"); + assert.equal(waiterResponse.result.structuredContent.cancelled, true); + assert.equal((await events(eventFile)).some((item) => item.name === "cancelled-waiter"), false); + assert.equal((await holder).result.structuredContent.ok, true); + } finally { + await secondClient.close(); + } +}); + +test("a cross-process lock waiter obeys the delegation deadline", async (context) => { + const { tempRoot, workspace, configPath, client } = await makeHarness(context); + const secondClient = new McpClient(configPath); + try { + await secondClient.initialize(); + const eventFile = path.join(tempRoot, "cross-process-deadline-events.jsonl"); + const holder = client.request("tools/call", taskArguments(workspace, { + name: "deadline-holder", eventFile, delayMs: 7_000, + }), 131); + await waitFor(async () => (await events(eventFile)).some( + (item) => item.name === "deadline-holder" && item.event === "start", + )); + const startedAt = Date.now(); + const waiter = secondClient.request("tools/call", taskArguments(workspace, { + name: "deadline-waiter", eventFile, + }, { timeoutMs: 5_000 }), 132); + + const waiterResponse = await waiter; + const elapsed = Date.now() - startedAt; + assert.ok(elapsed >= 4_500 && elapsed < 6_500, "lock wait should consume the overall deadline: " + String(elapsed)); + assert.equal(waiterResponse.result.structuredContent.timedOut, true); + assert.match(waiterResponse.result.structuredContent.error, /waiting for the workspace lock/iu); + assert.equal((await events(eventFile)).some((item) => item.name === "deadline-waiter"), false); + assert.equal((await holder).result.structuredContent.ok, true); + } finally { + await secondClient.close(); + } +}); + +test("losing a Git-ref lease never strands the local FIFO gate", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const initialized = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(initialized.result.structuredContent.ok, true, JSON.stringify(initialized)); + const canonicalRoot = await canonicalGitCommonDirectory(workspace); + const key = await repositoryKey(canonicalRoot); + const ref = workspaceLockRef(key); + const lockStore = await coordinationLockStore(workspace); + const eventFile = path.join(tempRoot, "lost-lock-events.jsonl"); + const first = client.request("tools/call", taskArguments(workspace, { + name: "loses-lock", eventFile, delayMs: 12_000, + }), 141); + await waitFor(async () => (await events(eventFile)).some( + (item) => item.name === "loses-lock" && item.event === "start", + )); + await waitFor(async () => { + try { + const { stdout: oid } = await execFileAsync("git", ["rev-parse", "--verify", ref], { cwd: lockStore }); + const { stdout: blob } = await execFileAsync("git", ["cat-file", "blob", oid.trim()], { cwd: lockStore }); + return JSON.parse(blob).workerState === "running"; + } catch { + return false; + } + }); + + const replacementPath = path.join(tempRoot, "replacement-owner.json"); + await writeFile(replacementPath, JSON.stringify({ version: 1, hostIdentity: "foreign:test" })); + const { stdout: replacementOidText } = await execFileAsync("git", ["hash-object", "-w", replacementPath], { cwd: lockStore }); + const replacementOid = replacementOidText.trim(); + const replacedAt = Date.now(); + await execFileAsync("git", ["update-ref", ref, replacementOid], { cwd: lockStore }); + context.after(async () => { + try { await execFileAsync("git", ["update-ref", "-d", ref, replacementOid], { cwd: lockStore }); } catch { /* already gone */ } + }); + + const firstResponse = await first; + assert.ok(Date.now() - replacedAt < 9_000, "heartbeat loss must terminate the active worker promptly"); + assert.match(firstResponse.error?.message ?? "", /workspace lock ownership changed/iu); + assert.equal((await events(eventFile)).some( + (item) => item.name === "loses-lock" && item.event === "end", + ), false, "the original 12-second worker should be terminated before normal completion"); + await execFileAsync("git", ["update-ref", "-d", ref, replacementOid], { cwd: lockStore }); + + const followUp = await client.request("tools/call", taskArguments(workspace, { + name: "after-lost-lock", eventFile, delayMs: 10, + }), 142); + assert.equal(followUp.result.structuredContent.ok, true, JSON.stringify(followUp)); + assert.ok((await events(eventFile)).some((item) => item.name === "after-lost-lock" && item.event === "end")); +}); + +test("unconfirmed termination after lease loss quarantines delegation and status", { + // This fixture forced taskkill failure through PATH. Windows worker cleanup + // now uses a kill-on-close Job and never calls taskkill, so the injection no + // longer exercises an unconfirmed-termination path. + skip: "obsolete taskkill fault injection; Job bootstrap failure is covered above", +}, async (context) => { + const { tempRoot, workspace, configPath } = await makeHarness(context); + const shimDirectory = path.join(tempRoot, "failing-taskkill"); + await mkdir(shimDirectory); + const windowsRoot = process.env.SystemRoot ?? "C:\\Windows"; + const realTaskkill = path.join(windowsRoot, "System32", "taskkill.exe"); + await copyFile(path.join(windowsRoot, "System32", "cmd.exe"), path.join(shimDirectory, "taskkill.exe")); + const client = new McpClient(configPath, { + NODE_ENV: "test", + CLI_AGENT_BRIDGE_TEST_KILL_GRACE_MS: "100", + PATH: shimDirectory + path.delimiter + process.env.PATH, + }); + const eventFile = path.join(tempRoot, "quarantine-events.jsonl"); + let workerPid = null; + let replacementOid = null; + let ref = null; + let lockStore = null; + let quarantinePath = null; + try { + await client.initialize(); + const canonicalRoot = await canonicalGitCommonDirectory(workspace); + ({ quarantinePath } = await repositoryStatePaths(canonicalRoot)); + context.after(() => rm(quarantinePath, { recursive: true, force: true })); + const key = await repositoryKey(canonicalRoot); + ref = workspaceLockRef(key); + lockStore = await coordinationLockStore(workspace); + const delegated = client.request("tools/call", taskArguments(workspace, { + name: "unconfirmed-tree", eventFile, delayMs: 60_000, + }), 151); + await waitFor(async () => { + const started = (await events(eventFile)).find( + (item) => item.name === "unconfirmed-tree" && item.event === "start", + ); + workerPid = started?.pid ?? null; + return Number.isInteger(workerPid); + }); + await waitFor(async () => { + try { + const { stdout: oid } = await execFileAsync("git", ["rev-parse", "--verify", ref], { cwd: lockStore }); + const { stdout: blob } = await execFileAsync("git", ["cat-file", "blob", oid.trim()], { cwd: lockStore }); + return JSON.parse(blob).workerState === "running"; + } catch { + return false; + } + }); + const replacementPath = path.join(tempRoot, "quarantine-replacement-owner.json"); + await writeFile(replacementPath, JSON.stringify({ version: 1, hostIdentity: "foreign:quarantine" })); + const replacement = await execFileAsync("git", ["hash-object", "-w", replacementPath], { cwd: lockStore }); + replacementOid = replacement.stdout.trim(); + const queuedStatus = client.request("tools/call", { + name: "workspace_status", + arguments: { workspacePath: workspace }, + }, 153); + await execFileAsync("git", ["update-ref", ref, replacementOid], { cwd: lockStore }); + + const failed = await delegated; + assert.match(failed.error?.message ?? "", /workspace lock ownership changed/iu); + const status = await queuedStatus; + assert.equal(status.result.structuredContent.git, null); + assert.match(status.result.structuredContent.error, /could not be confirmed terminated/iu); + const followUp = await client.request("tools/call", taskArguments(workspace, { + name: "must-not-start-after-quarantine", eventFile, + }), 152); + assert.equal(followUp.result.structuredContent.treeTerminated, false); + assert.match(followUp.result.structuredContent.error, /quarantined/iu); + assert.equal((await events(eventFile)).some( + (item) => item.name === "must-not-start-after-quarantine", + ), false); + + await execFileAsync(realTaskkill, ["/PID", String(workerPid), "/T", "/F"]); + workerPid = null; + await rm(quarantinePath, { recursive: true, force: true }); + await execFileAsync("git", ["update-ref", "-d", ref, replacementOid], { cwd: lockStore }); + replacementOid = null; + const recovered = await client.request("tools/call", taskArguments(workspace, { + name: "after-manual-quarantine-recovery", eventFile, delayMs: 10, + }), 154); + assert.equal(recovered.result.structuredContent.ok, true, JSON.stringify(recovered)); + } finally { + if (ref && replacementOid && lockStore) { + try { await execFileAsync("git", ["update-ref", "-d", ref, replacementOid], { cwd: lockStore }); } catch { /* already gone */ } + } + if (Number.isInteger(workerPid)) { + try { await execFileAsync(realTaskkill, ["/PID", String(workerPid), "/T", "/F"]); } catch { /* already gone */ } + } + await client.close(); + } +}); + +test("a quarantine marker blocks delegations in every server process", async (context) => { + const { workspace, configPath } = await makeHarness(context); + const secondClient = new McpClient(configPath); + try { + await secondClient.initialize(); + const initialized = await secondClient.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(initialized.result.structuredContent.ok, true, JSON.stringify(initialized)); + const { root, quarantinePath } = await repositoryStatePaths( + await canonicalGitCommonDirectory(workspace), + ); + await mkdir(root, { recursive: true }); + context.after(() => rm(quarantinePath, { recursive: true, force: true })); + await writeFile(quarantinePath, JSON.stringify({ terminationError: "fixture" })); + const response = await secondClient.request("tools/call", taskArguments(workspace, { + name: "must-not-run", writeFile: "quarantine-bypass.txt", + }), 131); + assert.equal(response.result.structuredContent.ok, false); + assert.equal(response.result.structuredContent.quarantinePath, quarantinePath); + assert.match(response.result.structuredContent.error, /quarantined/iu); + await assert.rejects(access(path.join(workspace, "quarantine-bypass.txt")), /ENOENT/u); + } finally { + await secondClient.close(); + } +}); + +test("quarantine state ignores attacker-precreated temporary roots", async (context) => { + const attackerTemp = await mkdtemp(path.join(os.tmpdir(), "cli-agent-attacker-temp-")); + context.after(() => rm(attackerTemp, { recursive: true, force: true })); + const user = os.userInfo(); + const identity = Number.isInteger(user.uid) && user.uid >= 0 + ? process.platform + ":uid:" + String(user.uid) + : process.platform + ":" + user.username + ":" + user.homedir; + const legacyRoot = path.join( + attackerTemp, + "minimax-cli-agent-bridge-locks-" + + createHash("sha256").update(identity).digest("hex").slice(0, 20), + ); + await mkdir(legacyRoot, { mode: 0o777 }); + if (process.platform !== "win32") await chmod(legacyRoot, 0o777); + const tempEnvironment = process.platform === "win32" + ? { TEMP: attackerTemp, TMP: attackerTemp } + : { TMPDIR: attackerTemp }; + const { workspace, client } = await makeHarness(context, { extraEnv: tempEnvironment }); + const initialized = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(initialized.result.structuredContent.ok, true, JSON.stringify(initialized)); + const commonDir = await canonicalGitCommonDirectory(workspace); + const key = await repositoryKey(commonDir); + const digest = createHash("sha256").update(key).digest("hex"); + const forgedPath = path.join(legacyRoot, digest + ".quarantine"); + await writeFile(forgedPath, JSON.stringify({ terminationError: "attacker-controlled marker" })); + + const response = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(response.result.structuredContent.ok, true, JSON.stringify(response)); + const state = await repositoryStatePaths(commonDir); + assert.equal( + state.root, + path.join(commonDir, "cli-agent-bridge-lock-store.git", "cli-agent-bridge-quarantines"), + ); + assert.notEqual(path.dirname(state.quarantinePath), legacyRoot); +}); + +test("a substituted quarantine-root link is rejected before marker state is trusted", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const initialized = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(initialized.result.structuredContent.ok, true, JSON.stringify(initialized)); + const state = await repositoryStatePaths(await canonicalGitCommonDirectory(workspace)); + const attackerDirectory = path.join(tempRoot, "attacker-controlled-quarantine-root"); + await mkdir(attackerDirectory); + await rm(state.root, { recursive: true, force: true }); + await symlink( + attackerDirectory, + state.root, + process.platform === "win32" ? "junction" : "dir", + ); + + const response = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.match(response.error?.message ?? "", /quarantine root must be a real directory/iu, + JSON.stringify(response)); + assert.deepEqual(await readdir(attackerDirectory), []); +}); + +test("quarantine publication uses an exclusive directory claim without hard links", async (context) => { + const quarantineRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-quarantine-store-test-")); + context.after(() => rm(quarantineRoot, { recursive: true, force: true })); + const key = "quarantine-directory-publication:" + String(process.pid) + ":" + String(Date.now()); + const quarantine = await markWorkspaceQuarantined(quarantineRoot, key, { + backend: "fixture", terminationError: "descendants remain uncertain", + }); + context.after(() => rm(quarantine.quarantinePath, { recursive: true, force: true })); + assert.equal((await stat(quarantine.quarantinePath)).isDirectory(), true); + const record = JSON.parse(await readFile( + path.join(quarantine.quarantinePath, "record.json"), "utf8", + )); + assert.equal(record.quarantineId, quarantine.quarantineId); + assert.equal(record.terminationError, "descendants remain uncertain"); + await assert.rejects( + markWorkspaceQuarantined( + quarantineRoot, key, { terminationError: "must not replace the first incident" }, + ), + /quarantine marker already exists/iu, + ); +}); + +test("quarantine recovery requires an explicit incident-bound approval", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const initialized = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(initialized.result.structuredContent.ok, true, JSON.stringify(initialized)); + + const commonDir = await canonicalGitCommonDirectory(workspace); + const lockStore = await coordinationLockStore(workspace); + const key = await repositoryKey(commonDir); + const ref = workspaceLockRef(key); + const quarantineId = "explicit-recovery-" + Date.now(); + const ownerPath = path.join(tempRoot, "quarantined-owner.json"); + const now = Date.now(); + await writeFile(ownerPath, JSON.stringify({ + version: 1, + token: "explicit-recovery-owner", + hostIdentity: localHostIdentity(), + ownerPid: client.child.pid, + ownerIdentity: null, + workerState: "quarantined", + quarantineMarkerPersisted: true, + quarantineId, + workerPid: 4242, + acquiredAt: now, + heartbeatAt: now, + })); + const { stdout: oid } = await execFileAsync("git", ["hash-object", "-w", ownerPath], { + cwd: lockStore, + }); + await execFileAsync("git", ["update-ref", ref, oid.trim()], { cwd: lockStore }); + + const { root, quarantinePath, recoveryPath } = await repositoryStatePaths(commonDir); + await mkdir(root, { recursive: true }); + context.after(() => rm(quarantinePath, { recursive: true, force: true })); + context.after(() => rm(recoveryPath, { recursive: true, force: true })); + const record = JSON.stringify({ quarantineId, terminationError: "fixture" }); + await writeFile(quarantinePath, record); + // Simulate routine temp cleanup. Absence alone must leave the live + // quarantined lease held and must not start a worker. + await unlink(quarantinePath); + const eventFile = path.join(tempRoot, "explicit-recovery-events.jsonl"); + const absentOnly = await client.request("tools/call", taskArguments(workspace, { + name: "must-not-run-after-marker-loss", eventFile, + }, { timeoutMs: 5_000 }), 132); + assert.equal(absentOnly.result.structuredContent.ok, false, JSON.stringify(absentOnly)); + assert.match(absentOnly.result.structuredContent.error, /timed out.*workspace lock/iu); + assert.equal((await events(eventFile)).length, 0); + + // Renaming the incident record is the documented explicit approval. The CAS + // winner consumes it only after acquiring the exact quarantined lease. + await mkdir(quarantinePath); + await writeFile(path.join(quarantinePath, "record.json"), record); + await rename(quarantinePath, recoveryPath); + const recovered = await client.request("tools/call", taskArguments(workspace, { + name: "after-explicit-recovery", eventFile, delayMs: 10, + }, { timeoutMs: 20_000 }), 133); + assert.equal(recovered.result.structuredContent.ok, true, JSON.stringify(recovered)); + await assert.rejects(access(recoveryPath), /ENOENT/u, + "the successful CAS owner must consume the recovery authorization"); +}); + +test("a request cancelled while queued never starts its backend", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const eventFile = path.join(tempRoot, "events.jsonl"); + const first = client.request("tools/call", taskArguments(workspace, { + name: "holder", eventFile, delayMs: 3_000, + }), 101); + await waitFor(async () => (await events(eventFile)).some((item) => item.name === "holder" && item.event === "start")); + const queued = client.request("tools/call", taskArguments(path.join(workspace, "."), { + name: "cancelled", eventFile, writeFile: "must-not-exist.txt", + }), 102); + await new Promise((resolve) => setTimeout(resolve, 100)); + const cancelledAt = Date.now(); + client.notify("notifications/cancelled", { requestId: 102, reason: "test cancellation" }); + + const queuedResponse = await queued; + assert.ok(Date.now() - cancelledAt < 1_500, "queued cancellation should bypass the held workspace lock"); + const third = client.request("tools/call", taskArguments(workspace, { + name: "third", eventFile, delayMs: 10, + }), 103); + const [firstResponse, thirdResponse] = await Promise.all([first, third]); + assert.equal(firstResponse.result.structuredContent.ok, true); + assert.equal(thirdResponse.result.structuredContent.ok, true); + assert.equal(queuedResponse.result.isError, true); + assert.equal(queuedResponse.result.structuredContent.cancelled, true); + assert.equal((await events(eventFile)).some((item) => item.name === "cancelled"), false); + assert.deepEqual((await events(eventFile)).map((item) => item.event + ":" + item.name), [ + "start:holder", "end:holder", "start:third", "end:third", + ]); + await assert.rejects(access(path.join(workspace, "must-not-exist.txt")), /ENOENT/u); +}); + +test("cancellation interrupts initial Git repository discovery", { + skip: process.platform === "win32", +}, async (context) => { + const { tempRoot, workspace, configPath } = await makeHarness(context); + const eventFile = path.join(tempRoot, "git-discovery-events.jsonl"); + const wrapper = path.join(tempRoot, "git"); + await writeFile(wrapper, [ + "#!/usr/bin/env node", + "const { appendFileSync } = require('node:fs');", + "appendFileSync(" + JSON.stringify(eventFile) + ", JSON.stringify({event:'git-start'}) + '\\n');", + "setTimeout(() => {}, 60000);", + ].join("\n")); + await chmod(wrapper, 0o755); + const delayedClient = new McpClient(configPath, { + PATH: tempRoot + path.delimiter + process.env.PATH, + }); + try { + await delayedClient.initialize(); + const pending = delayedClient.request("tools/call", taskArguments(workspace, { + name: "must-not-start", writeFile: "discovery-cancelled.txt", + }), 111); + await waitFor(async () => (await events(eventFile)).some((item) => item.event === "git-start")); + const cancelledAt = Date.now(); + delayedClient.notify("notifications/cancelled", { requestId: 111 }); + const response = await pending; + assert.ok(Date.now() - cancelledAt < 1_500, "Git discovery cancellation should settle promptly"); + assert.equal(response.result.structuredContent.cancelled, true); + await assert.rejects(access(path.join(workspace, "discovery-cancelled.txt")), /ENOENT/u); + } finally { + await delayedClient.close(); + } +}); + +test("cancellation interrupts workspace filesystem canonicalization", async (context) => { + const { workspace, configPath } = await makeHarness(context); + const delayedClient = new McpClient(configPath, { + NODE_ENV: "test", + CLI_AGENT_BRIDGE_TEST_WORKSPACE_VALIDATION_DELAY_MS: "10000", + }); + context.after(() => delayedClient.close()); + await delayedClient.initialize(); + const request = delayedClient.request("tools/call", taskArguments(workspace, { + name: "must-not-start", writeFile: "canonicalization-bypass.txt", + }), 611); + await new Promise((resolve) => setTimeout(resolve, 100)); + const cancelledAt = Date.now(); + delayedClient.notify("notifications/cancelled", { requestId: 611 }); + const response = await request; + assert.ok(Date.now() - cancelledAt < 1_500, + "filesystem validation must not pin the request after cancellation"); + assert.equal(response.result.structuredContent.cancelled, true); + await assert.rejects(access(path.join(workspace, "canonicalization-bypass.txt")), /ENOENT/u); +}); + +test("quarantine marker operations obey cancellation and preflight deadlines", async () => { + const marker = { isDirectory: () => true, isFile: () => false }; + for (const stalled of ["stat", "readFile", "realpath"]) { + const cancel = testCancellation(); + let started = false; + const pending = new Promise(() => {}); + const operation = readWorkspaceQuarantine("quarantine-root", "repository-key", { + cancel, + fsOps: { + stat: () => { + if (stalled === "stat") { + started = true; + return pending; + } + return Promise.resolve(marker); + }, + readFile: () => { + if (stalled === "readFile") { + started = true; + return pending; + } + return Promise.resolve(JSON.stringify({ quarantineId: "fixture" })); + }, + realpath: () => { + if (stalled === "realpath") { + started = true; + return pending; + } + return Promise.resolve("quarantine-record"); + }, + }, + }); + await waitFor(() => started); + cancel.cancel(); + await assert.rejects(operation, /cancelled by client/iu, stalled); + } + + let calls = 0; + await assert.rejects( + readWorkspaceQuarantine("quarantine-root", "repository-key", { + deadline: Date.now() - 1, + fsOps: { + stat: () => { calls += 1; return Promise.resolve(marker); }, + readFile: () => { calls += 1; return Promise.resolve("{}"); }, + realpath: () => { calls += 1; return Promise.resolve("quarantine-record"); }, + }, + }), + /deadline exceeded/iu, + ); + assert.equal(calls, 0, "an expired request must not start quarantine filesystem I/O"); +}); + +test("quarantine reads cannot pin cancellation before or after lease acquisition", async (context) => { + const scenarios = [ + { label: "before-acquire", targetCall: 1, status: false, requestId: 621 }, + { label: "after-acquire", targetCall: 3, status: false, requestId: 622 }, + { label: "workspace-status", targetCall: 1, status: true, requestId: 623 }, + { label: "deadline-before-acquire", targetCall: 1, status: false, deadline: true, requestId: 624 }, + ]; + for (const scenario of scenarios) { + const startedRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-quarantine-read-test-")); + const startedFile = path.join(startedRoot, "started.txt"); + context.after(() => rm(startedRoot, { recursive: true, force: true })); + const { tempRoot, workspace, client } = await makeHarness(context, { + extraEnv: { + NODE_ENV: "test", + CLI_AGENT_BRIDGE_TEST_QUARANTINE_READ_DELAY_MS: "60000", + CLI_AGENT_BRIDGE_TEST_QUARANTINE_READ_DELAY_CALL: String(scenario.targetCall), + CLI_AGENT_BRIDGE_TEST_QUARANTINE_READ_STARTED_FILE: startedFile, + }, + }); + const eventFile = path.join(tempRoot, scenario.label + "-events.jsonl"); + const pending = scenario.status + ? client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }, scenario.requestId) + : client.request("tools/call", taskArguments(workspace, { + name: scenario.label, eventFile, + }, { timeoutMs: scenario.deadline ? 5_000 : 20_000 }), scenario.requestId); + await waitFor(() => access(startedFile).then(() => true, () => false)); + if (scenario.deadline) { + const response = await pending; + assert.equal(response.result.structuredContent.timedOut, true, JSON.stringify(response)); + assert.equal((await events(eventFile)).length, 0, "the backend must never start"); + continue; + } + const cancelledAt = Date.now(); + client.notify("notifications/cancelled", { requestId: scenario.requestId }); + const response = await pending; + assert.ok(Date.now() - cancelledAt < 1_500, + scenario.label + " quarantine read must settle promptly after cancellation"); + assert.ok(response.result, JSON.stringify(response)); + assert.equal(response.result.structuredContent.cancelled, true, JSON.stringify(response)); + assert.equal((await events(eventFile)).length, 0, "the backend must never start"); + } +}); + +test("backend command resolution obeys cancellation and the absolute request deadline", async (context) => { + const resolutionRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-resolution-deadline-test-")); + const resolutionStartedFile = path.join(resolutionRoot, "started.txt"); + context.after(() => rm(resolutionRoot, { recursive: true, force: true })); + const { tempRoot, workspace, client } = await makeHarness(context, { + extraEnv: { + NODE_ENV: "test", + CLI_AGENT_BRIDGE_TEST_COMMAND_RESOLUTION_DELAY_MS: "60000", + CLI_AGENT_BRIDGE_TEST_COMMAND_RESOLUTION_STARTED_FILE: resolutionStartedFile, + }, + }); + const eventFile = path.join(tempRoot, "resolution-must-not-start.jsonl"); + const cancelledRequest = client.request("tools/call", taskArguments(workspace, { + name: "cancelled-during-resolution", eventFile, + }, { timeoutMs: 20_000 }), 612); + await waitFor(() => access(resolutionStartedFile).then(() => true, () => false)); + const cancelledAt = Date.now(); + client.notify("notifications/cancelled", { requestId: 612 }); + const cancelled = await cancelledRequest; + assert.ok(Date.now() - cancelledAt < 1_500, + "command resolution must stop pinning a cancelled request promptly"); + assert.equal(cancelled.result.structuredContent.cancelled, true, JSON.stringify(cancelled)); + + const deadlineStarted = Date.now(); + const timedOut = await client.request("tools/call", taskArguments(workspace, { + name: "timed-out-during-resolution", eventFile, + }, { timeoutMs: 5_000 }), 613); + const elapsed = Date.now() - deadlineStarted; + assert.ok(elapsed >= 4_500 && elapsed < 8_000, + "command resolution must use the request's existing absolute deadline"); + assert.equal(timedOut.result.structuredContent.timedOut, true, JSON.stringify(timedOut)); + assert.equal(timedOut.result.structuredContent.treeTerminated, true); + assert.match(timedOut.result.structuredContent.error, /resolving the backend command/iu); + assert.equal((await readFile(resolutionStartedFile, "utf8")).trim().split(/\r?\n/u).length, 1, + "cancelled and timed-out callers must share the same underlying filesystem lookup"); + assert.equal((await events(eventFile)).length, 0, "the backend must never be launched"); +}); + +test("cancellation terminates descendants before returning", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const eventFile = path.join(tempRoot, "events.jsonl"); + const delegated = client.request("tools/call", taskArguments(workspace, { + name: "tree", + eventFile, + spawnDescendant: true, + descendantDelayMs: 1_200, + descendantWriteFile: "descendant-survived.txt", + }), 201); + await waitFor(async () => (await events(eventFile)).some((item) => item.event === "descendant-start")); + client.notify("notifications/cancelled", { requestId: 201, reason: "test process-tree cancellation" }); + + const response = await delegated; + assert.ok(response.result, JSON.stringify(response)); + assert.equal(response.result.isError, true); + assert.equal(response.result.structuredContent.cancelled, true); + assert.equal(response.result.structuredContent.treeTerminated, true, JSON.stringify(response)); + const followUp = await client.request("tools/call", taskArguments(workspace, { + name: "after-cancel", writeFile: "follow-up.txt", contents: "safe\n", + })); + assert.equal(followUp.result.structuredContent.ok, true); + await new Promise((resolve) => setTimeout(resolve, 1_400)); + await assert.rejects(access(path.join(workspace, "descendant-survived.txt")), /ENOENT/u); +}); + +test("cancellation terminates a descendant that creates a new POSIX session", { + skip: process.platform === "win32", +}, async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const eventFile = path.join(tempRoot, "detached-descendant-events.jsonl"); + const delegated = client.request("tools/call", taskArguments(workspace, { + name: "detached-tree", + eventFile, + spawnDescendant: true, + detachedDescendant: true, + descendantDelayMs: 1_200, + descendantWriteFile: "detached-descendant-survived.txt", + }), 211); + await waitFor(async () => (await events(eventFile)).some((item) => item.event === "descendant-start")); + // Give the 25 ms ancestry monitor time to record the detached child before cancellation. + await new Promise((resolve) => setTimeout(resolve, 100)); + client.notify("notifications/cancelled", { requestId: 211 }); + + const response = await delegated; + assert.equal(response.result.structuredContent.cancelled, true); + assert.equal(response.result.structuredContent.treeTerminated, true); + await new Promise((resolve) => setTimeout(resolve, 1_400)); + await assert.rejects(access(path.join(workspace, "detached-descendant-survived.txt")), /ENOENT/u); +}); + +test("a detached child remains contained when its parent exits before ancestry polling", { + skip: process.platform !== "linux", +}, async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const eventFile = path.join(tempRoot, "fast-parent-events.jsonl"); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "fast-parent", + eventFile, + spawnDescendant: true, + detachedDescendant: true, + parentDelayMs: 0, + descendantDelayMs: 1_200, + descendantWriteFile: "fast-parent-descendant-survived.txt", + })); + const out = response.result.structuredContent; + assert.equal(out.treeTerminated, true, JSON.stringify(out)); + assert.equal(out.orphanedProcesses, true, + "the close path discovers the reparented child through its inherited run marker"); + await new Promise((resolve) => setTimeout(resolve, 1_400)); + await assert.rejects(access(path.join(workspace, "fast-parent-descendant-survived.txt")), /ENOENT/u); +}); + +test("Windows Job cleanup removes a detached child after normal backend exit", { + skip: process.platform !== "win32", +}, async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const eventFile = path.join(tempRoot, "windows-job-detached-events.jsonl"); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "windows-job-detached", + eventFile, + spawnDescendant: true, + detachedDescendant: true, + parentDelayMs: 0, + descendantDelayMs: 1_200, + descendantWriteFile: "windows-job-descendant-survived.txt", + })); + const out = response.result.structuredContent; + assert.equal(out.treeTerminated, true, JSON.stringify(out)); + await new Promise((resolve) => setTimeout(resolve, 1_400)); + await assert.rejects( + access(path.join(workspace, "windows-job-descendant-survived.txt")), /ENOENT/u, + ); +}); + +test("timeout terminates descendants before releasing the request", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const eventFile = path.join(tempRoot, "events.jsonl"); + const startedAt = Date.now(); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "timeout-tree", + eventFile, + spawnDescendant: true, + descendantDelayMs: 8_000, + descendantWriteFile: "timeout-descendant-survived.txt", + }, { timeoutMs: 5_000 }), 202); + + assert.ok(response.result, JSON.stringify(response)); + assert.equal(response.result.isError, true); + assert.equal(response.result.structuredContent.timedOut, true); + assert.equal(response.result.structuredContent.treeTerminated, true); + const remaining = Math.max(0, 8_500 - (Date.now() - startedAt)); + await new Promise((resolve) => setTimeout(resolve, remaining)); + await assert.rejects(access(path.join(workspace, "timeout-descendant-survived.txt")), /ENOENT/u); +}); + +test("workspace status and delegation support an unborn HEAD", async (context) => { + const { workspace, client } = await makeHarness(context, { unborn: true }); + const status = await client.request("tools/call", { + name: "workspace_status", + arguments: { workspacePath: workspace }, + }); + assert.equal(status.result.structuredContent.ok, true); + assert.equal(status.result.structuredContent.git.head, ""); + + const delegated = await client.request("tools/call", taskArguments(workspace, { + name: "unborn", writeFile: "created.txt", contents: "created\n", + })); + assert.equal(delegated.result.structuredContent.ok, true); + assert.equal(delegated.result.structuredContent.gitBefore.head, ""); + assert.deepEqual(delegated.result.structuredContent.git.changedFiles, ["created.txt"]); +}); + +test("commits on a new branch are reported when the worker returns to the original HEAD", async (context) => { + const { workspace, client } = await makeHarness(context); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "branch-round-trip", + branchRoundTrip: true, + branchName: "worker-created-branch", + writeFile: "branch-only.txt", + commitMessage: "commit outside final HEAD", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify({ + error: out.error, terminationError: out.terminationError, + })); + assert.equal(out.gitBefore.head, out.git.head, "worker must return to the original HEAD"); + assert.deepEqual(out.git.changedFiles, []); + assert.ok(out.commits, "ref changes must produce a commits block even when HEAD is unchanged"); + assert.ok(out.commits.refsChanged.some((item) => + item.ref === "refs/heads/worker-created-branch" && !item.before && item.after, + ), JSON.stringify(out.commits.refsChanged)); + assert.match(out.commits.log, /commit outside final HEAD/u); +}); + +test("changedFiles preserves unusual names and scans from the worktree root", async (context) => { + const { workspace, client } = await makeHarness(context); + const nested = path.join(workspace, "nested", "deep"); + await mkdir(nested, { recursive: true }); + await execFileAsync("git", ["config", "core.quotePath", "true"], { cwd: workspace }); + const leading = path.join(nested, " leading.txt"); + await writeFile(leading, "before\n"); + await execFileAsync("git", ["add", "nested/deep/ leading.txt"], { cwd: workspace }); + await execFileAsync("git", ["commit", "-m", "add unusual path"], { cwd: workspace }); + await writeFile(leading, "after\n"); + await writeFile(path.join(nested, "café-staged.txt"), "staged\n"); + await execFileAsync("git", ["add", "nested/deep/café-staged.txt"], { cwd: workspace }); + await writeFile(path.join(nested, "café-untracked.txt"), "untracked\n"); + await writeFile(path.join(workspace, "root-new.txt"), "root\n"); + + const status = await client.request("tools/call", { + name: "workspace_status", + arguments: { workspacePath: nested }, + }); + const names = status.result.structuredContent.git.changedFiles; + assert.ok(names.includes("nested/deep/ leading.txt"), JSON.stringify(names)); + assert.ok(names.includes("nested/deep/café-staged.txt"), JSON.stringify(names)); + assert.ok(names.includes("nested/deep/café-untracked.txt"), JSON.stringify(names)); + assert.ok(names.includes("root-new.txt"), JSON.stringify(names)); + assert.equal(status.result.structuredContent.worktreeRoot, await realpath(workspace)); +}); + +test("changedFiles losslessly represents non-UTF-8 Git path bytes", { + // Linux filesystems expose arbitrary byte names. macOS normalizes/rejects + // invalid UTF-8 (EILSEQ), and Windows filenames use Unicode APIs. + skip: process.platform !== "linux", +}, async (context) => { + const { workspace, client } = await makeHarness(context); + const rawName = Buffer.from([0x62, 0x61, 0x64, 0x2d, 0x80, 0x2e, 0x74, 0x78, 0x74]); + const rawPath = Buffer.concat([Buffer.from(workspace), Buffer.from(path.sep), rawName]); + await writeFile(rawPath, "invalid UTF-8 filename\n"); + const status = await client.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.ok(status.result.structuredContent.git.changedFiles.includes( + "\0git-path-bytes:" + rawName.toString("hex"), + ), JSON.stringify(status.result.structuredContent.git.changedFiles)); +}); + +test("truncated backend capture is disclosed instead of presented as complete", async (context) => { + const { workspace, client } = await makeHarness(context); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "large-output", stdoutChars: 5_100_000, + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true); + assert.equal(out.outputTruncated, true); + assert.ok(out.outputTail.length <= 60_000); +}); + +test("numeric and string JSON-RPC request IDs remain distinct", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const secondWorkspace = path.join(tempRoot, "workspace-two"); + await mkdir(secondWorkspace); + await execFileAsync("git", ["init", "-b", "main"], { cwd: secondWorkspace }); + await execFileAsync("git", ["config", "user.name", "Bridge Test"], { cwd: secondWorkspace }); + await execFileAsync("git", ["config", "user.email", "bridge-test@example.invalid"], { cwd: secondWorkspace }); + await writeFile(path.join(secondWorkspace, "baseline.txt"), "baseline\n"); + await execFileAsync("git", ["add", "baseline.txt"], { cwd: secondWorkspace }); + await execFileAsync("git", ["commit", "-m", "baseline"], { cwd: secondWorkspace }); + const eventFile = path.join(tempRoot, "typed-id-events.jsonl"); + + const numeric = client.request("tools/call", taskArguments(workspace, { + name: "numeric", eventFile, delayMs: 2_500, writeFile: "numeric.txt", + }), 301); + const string = client.request("tools/call", taskArguments(secondWorkspace, { + name: "string", eventFile, delayMs: 300, writeFile: "string.txt", + }), "301"); + await waitFor(async () => { + const seen = await events(eventFile); + return seen.some((item) => item.name === "numeric" && item.event === "start") && + seen.some((item) => item.name === "string" && item.event === "start"); + }); + client.notify("notifications/cancelled", { requestId: 301 }); + + const [numericResponse, stringResponse] = await Promise.all([numeric, string]); + assert.equal(numericResponse.result.structuredContent.cancelled, true); + assert.equal(stringResponse.result.structuredContent.ok, true); + await assert.rejects(access(path.join(workspace, "numeric.txt")), /ENOENT/u); + await access(path.join(secondWorkspace, "string.txt")); +}); + +test("workspace_status waits for an active delegation on the same worktree", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const nested = path.join(workspace, "nested"); + await mkdir(nested); + const eventFile = path.join(tempRoot, "status-lock-events.jsonl"); + const delegated = client.request("tools/call", taskArguments(workspace, { + name: "holder", eventFile, delayMs: 1_200, writeFile: "finished.txt", + })); + await waitFor(async () => (await events(eventFile)).some((item) => item.event === "start")); + const status = client.request("tools/call", { + name: "workspace_status", + arguments: { workspacePath: nested }, + }); + const early = await Promise.race([ + status.then(() => "completed"), + new Promise((resolve) => setTimeout(() => resolve("pending"), 250)), + ]); + assert.equal(early, "pending", "status snapshot must wait for the delegation lock"); + const [delegatedResponse, statusResponse] = await Promise.all([delegated, status]); + assert.equal(delegatedResponse.result.structuredContent.ok, true); + assert.ok(statusResponse.result.structuredContent.git.changedFiles.includes("finished.txt")); +}); + +test("workspace_status can be cancelled while queued for the workspace lock", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const eventFile = path.join(tempRoot, "status-cancel-events.jsonl"); + const delegated = client.request("tools/call", taskArguments(workspace, { + name: "holder", eventFile, delayMs: 2_000, + }), 501); + await waitFor(async () => (await events(eventFile)).some((item) => item.event === "start")); + const status = client.request("tools/call", { + name: "workspace_status", + arguments: { workspacePath: workspace }, + }, 502); + await new Promise((resolve) => setTimeout(resolve, 100)); + const cancelledAt = Date.now(); + client.notify("notifications/cancelled", { requestId: 502 }); + + const statusResponse = await status; + assert.ok(Date.now() - cancelledAt < 1_500, "queued status cancellation should settle promptly"); + assert.equal(statusResponse.result.isError, true); + assert.equal(statusResponse.result.structuredContent.cancelled, true); + assert.equal(statusResponse.result.structuredContent.git, null); + assert.equal((await delegated).result.structuredContent.ok, true); +}); + +test("an unterminated oversized JSON-RPC line is rejected and closes the server", async (context) => { + const child = spawn(process.execPath, [serverPath], { + env: process.env, + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + context.after(() => { if (child.exitCode === null) child.kill(); }); + const exited = new Promise((resolve) => child.once("exit", (code) => resolve(code))); + child.stdin.write("x".repeat(1_000_001)); + let timer; + const code = await Promise.race([ + exited, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("oversized unterminated line did not close server")), 3_000); + }), + ]).finally(() => clearTimeout(timer)); + assert.equal(code, 1, stderr); + const response = JSON.parse(stdout.trim().split(/\r?\n/u)[0]); + assert.equal(response.error?.code, -32700, stdout); + assert.match(response.error?.message ?? "", /size limit/iu); +}); + +test("the stdio limit applies to each line rather than the whole input chunk", async () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + let output = ""; + let oversized = false; + stdout.setEncoding("utf8"); + stdout.on("data", (chunk) => { output += chunk; }); + startStdioServer({ stdin, stdout, onOversizedLine: () => { oversized = true; } }); + const line = (id) => JSON.stringify({ + jsonrpc: "2.0", id, method: "ping", params: { padding: "x".repeat(600_000) }, + }); + stdin.end(line(63_101) + "\n" + line(63_102) + "\n"); + await waitFor(() => output.trim().split(/\r?\n/u).filter(Boolean).length === 2); + assert.equal(oversized, false, "two legal lines must not be rejected because their chunk is large"); + assert.deepEqual(output.trim().split(/\r?\n/u).map((entry) => JSON.parse(entry).id), [ + 63_101, 63_102, + ]); +}); + +test("oversized JSON-RPC input uses awaited shutdown and releases an active lease", async (context) => { + const { tempRoot, workspace, configPath, client } = await makeHarness(context); + const eventFile = path.join(tempRoot, "oversized-shutdown-events.jsonl"); + const pending = client.request("tools/call", taskArguments(workspace, { + name: "oversized-shutdown-worker", eventFile, delayMs: 60_000, + }), 63_001); + void pending.catch(() => {}); + await waitFor(async () => (await events(eventFile)).some((item) => item.event === "start")); + const exited = new Promise((resolve) => client.child.once("exit", resolve)); + const started = Date.now(); + client.child.stdin.write("x".repeat(1_000_001)); + await exited; + assert.ok(Date.now() - started < 12_000, + "oversized input must await active cleanup without leaving the server alive"); + + const replacement = new McpClient(configPath); + await replacement.initialize(); + context.after(() => replacement.close()); + const status = await replacement.request("tools/call", { + name: "workspace_status", arguments: { workspacePath: workspace }, + }); + assert.equal(status.result.structuredContent.ok, true, JSON.stringify(status)); +}); + +test("closing MCP stdin terminates active worker descendants", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const eventFile = path.join(tempRoot, "shutdown-events.jsonl"); + const pending = client.request("tools/call", taskArguments(workspace, { + name: "shutdown-tree", + eventFile, + spawnDescendant: true, + descendantDelayMs: 1_500, + descendantWriteFile: "shutdown-descendant-survived.txt", + }), 401).catch(() => null); + await waitFor(async () => (await events(eventFile)).some((item) => item.event === "descendant-start")); + await client.disconnectInput(); + await pending; + await new Promise((resolve) => setTimeout(resolve, 1_700)); + await assert.rejects(access(path.join(workspace, "shutdown-descendant-survived.txt")), /ENOENT/u); +}); + +test("broken MCP stdout awaits active worker shutdown and lease release", async (context) => { + const { tempRoot, workspace, configPath, client } = await makeHarness(context); + const secondWorkspace = path.join(tempRoot, "stdout-late-workspace"); + await mkdir(secondWorkspace); + await initializeFixtureRepository(secondWorkspace); + const eventFile = path.join(tempRoot, "stdout-shutdown-events.jsonl"); + const survivor = path.join(workspace, "stdout-descendant-survived.txt"); + const pending = client.request("tools/call", taskArguments(workspace, { + name: "stdout-shutdown-tree", + eventFile, + spawnDescendant: true, + detachedDescendant: true, + ignoreSigterm: true, + descendantDelayMs: 1_500, + descendantWriteFile: path.basename(survivor), + }), 411).catch(() => null); + await waitFor(async () => (await events(eventFile)).some( + (item) => item.event === "descendant-start", + )); + + const exited = new Promise((resolve) => client.child.once("exit", resolve)); + client.child.stdout.destroy(); + // Force a response write while the delegation above is active. The broken + // pipe must enter the awaited shutdown path instead of crashing immediately. + client.child.stdin.write(JSON.stringify({ + jsonrpc: "2.0", id: 412, method: "tools/list", params: {}, + }) + "\n", () => {}); + // Keep the first cleanup in flight on Linux, then attempt a request for an + // independent repository. Without a dispatch gate it can start after the + // shutdown snapshot and escape the awaited cleanup set. + await new Promise((resolve) => setTimeout(resolve, 100)); + if (client.child.exitCode === null) { + client.child.stdin.write(JSON.stringify({ + jsonrpc: "2.0", + id: 414, + method: "tools/call", + params: taskArguments(secondWorkspace, { + name: "must-not-start-after-stdout-shutdown", eventFile, delayMs: 60_000, + }), + }) + "\n", () => {}); + } + await Promise.race([ + exited, + new Promise((_, reject) => setTimeout( + () => reject(new Error("server did not exit after stdout disconnected")), 15_000, + )), + ]); + await pending; + await new Promise((resolve) => setTimeout(resolve, 1_700)); + await assert.rejects(access(survivor), /ENOENT/u, + "the server must not exit before its detached worker tree is terminated"); + assert.equal((await events(eventFile)).some( + (item) => item.name === "must-not-start-after-stdout-shutdown", + ), false, "shutdown must close dispatch before snapshotting active requests"); + + const replacement = new McpClient(configPath); + context.after(() => replacement.close()); + await replacement.initialize(); + const recovered = await replacement.request("tools/call", taskArguments(workspace, { + name: "after-stdout-shutdown", delayMs: 10, + }), 413); + assert.equal(recovered.result.structuredContent.ok, true, JSON.stringify(recovered)); +}); + +test("checking out a pre-existing divergent branch is not reported as worker commits", async (context) => { + const { workspace, client } = await makeHarness(context); + // Create a divergent branch whose commits predate the delegation. + await execFileAsync("git", ["checkout", "-b", "divergent"], { cwd: workspace }); + await writeFile(path.join(workspace, "divergent.txt"), "pre-existing history\n"); + await execFileAsync("git", ["add", "divergent.txt"], { cwd: workspace }); + await execFileAsync("git", ["commit", "-m", "pre-existing divergent commit"], { cwd: workspace }); + await execFileAsync("git", ["checkout", "main"], { cwd: workspace }); + + const response = await client.request("tools/call", taskArguments(workspace, { + name: "checkout-only", checkoutExisting: true, branchName: "divergent", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out.error)); + assert.notEqual(out.gitBefore.head, out.git.head, "worker must have moved HEAD"); + assert.ok(out.commits, "a HEAD movement must still produce a commits block"); + assert.match(out.commits.log, /branch checkout or reset/u); + assert.doesNotMatch(out.commits.log, /pre-existing divergent commit/u, + "history that predates the delegation must not be attributed to the worker"); +}); + +test("switching symbolic HEAD at the same commit is reported", async (context) => { + const { workspace, client } = await makeHarness(context); + await execFileAsync("git", ["branch", "same-tip"], { cwd: workspace }); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "same-tip-checkout", checkoutExisting: true, branchName: "same-tip", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out.error)); + assert.equal(out.gitBefore.head, out.git.head); + assert.equal(out.gitBefore.headRef, "refs/heads/main"); + assert.equal(out.git.headRef, "refs/heads/same-tip"); + assert.ok(out.commits); + assert.match(out.commits.log, /HEAD symbolic target refs\/heads\/main -> refs\/heads\/same-tip/u); +}); + +test("a worker ref pointing at a non-commit object is reported without failing the delegation", async (context) => { + const { workspace, client } = await makeHarness(context); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "blob-tag", blobTag: true, refName: "refs/tags/blobtag", + })); + assert.equal(response.result.error, undefined, "the delegation must not surface a JSON-RPC error"); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out.error)); + assert.ok(out.commits.refsChanged.some((item) => item.ref === "refs/tags/blobtag" && item.after), + JSON.stringify(out.commits.refsChanged)); + assert.match(out.commits.log, /non-commit object/u); +}); + +test("a worker commit reachable only through a new tag remains attributed", async (context) => { + const { workspace, client } = await makeHarness(context); + const refName = "refs/tags/tag-only-worker-commit"; + const temporaryBranch = "temporary-tag-only-worker-commit"; + const response = await client.request("tools/call", taskArguments(workspace, { + name: "tag-only-worker-commit", + moveBlobRefToCommit: true, + refName, + branchName: temporaryBranch, + writeFile: "tag-only-worker.txt", + commitMessage: "worker commit retained only by tag", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out)); + assert.equal(out.gitBefore.head, out.git.head, "the temporary branch must not move final HEAD"); + assert.deepEqual(out.commits.refsChanged.map((item) => item.ref), [refName]); + assert.equal(out.commits.newCommitCount, 1, out.commits.log); + assert.equal((out.commits.log.match(/worker commit retained only by tag/gu) ?? []).length, 1); + assert.match(out.commits.diffStat, /tag-only-worker\.txt/u); + await assert.rejects( + execFileAsync("git", ["rev-parse", "--verify", "refs/heads/" + temporaryBranch], { + cwd: workspace, + }), + ); +}); + +test("target refs resembling coordination refs remain visible to attribution", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const refName = WORKSPACE_LOCK_REF_PREFIX + "legitimate-target-ref"; + const oldBlobPath = path.join(tempRoot, "legitimate-target-ref-blob.txt"); + await writeFile(oldBlobPath, "old target ref blob\n"); + const { stdout: blobOid } = await execFileAsync( + "git", ["hash-object", "-w", oldBlobPath], { cwd: workspace }, + ); + await execFileAsync("git", ["update-ref", refName, blobOid.trim()], { cwd: workspace }); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "legitimate-coordination-like-ref", moveBlobRefToCommit: true, refName, + writeFile: "legitimate-ref-commit.txt", commitMessage: "commit behind legitimate target ref", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out)); + assert.ok(out.commits.refsChanged.some((item) => item.ref === refName && item.after), + JSON.stringify(out.commits.refsChanged)); + assert.match(out.commits.log, /commit behind legitimate target ref/u); + assert.match(out.commits.diffStat, /legitimate-ref-commit\.txt/u); +}); + +test("a ref moved from a blob to a new commit uses a commit-safe diff base", async (context) => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-commit-safe-base-test-")); + const workspace = path.join(tempRoot, "workspace"); + await mkdir(workspace); + await initializeFixtureRepository(workspace); + context.after(() => rm(tempRoot, { recursive: true, force: true })); + const refName = "refs/tags/blob-to-commit"; + const oldBlobPath = path.join(tempRoot, "old-blob.txt"); + await writeFile(oldBlobPath, "old blob\n"); + const { stdout: blobOid } = await execFileAsync( + "git", ["hash-object", "-w", oldBlobPath], { cwd: workspace }, + ); + await execFileAsync("git", ["update-ref", refName, blobOid.trim()], { cwd: workspace }); + const { stdout: headText } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workspace }); + const head = headText.trim(); + const before = { + head, + headRef: "refs/heads/main", + refs: { "refs/heads/main": head, [refName]: blobOid.trim() }, + fetchHeads: [], + }; + + await execFileAsync("git", ["checkout", "-b", "temporary-ref-commit"], { cwd: workspace }); + await writeFile(path.join(workspace, "ref-commit.txt"), "ref commit\n"); + await execFileAsync("git", ["add", "ref-commit.txt"], { cwd: workspace }); + await execFileAsync("git", ["commit", "-m", "commit behind moved ref"], { cwd: workspace }); + const { stdout: newCommitText } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: workspace, + }); + const newCommit = newCommitText.trim(); + await execFileAsync("git", ["checkout", "main"], { cwd: workspace }); + await execFileAsync("git", ["branch", "-D", "temporary-ref-commit"], { cwd: workspace }); + await execFileAsync("git", ["update-ref", refName, newCommit], { cwd: workspace }); + const after = { + head, + headRef: "refs/heads/main", + refs: { "refs/heads/main": head, [refName]: newCommit }, + fetchHeads: [], + }; + + const commits = await committedDelta(workspace, before, after); + assert.match(commits.log, /commit behind moved ref/u); + assert.match(commits.diffStat, /ref-commit\.txt/u); + assert.doesNotMatch(commits.diffStat, new RegExp(blobOid.trim(), "u")); +}); + +test("a moved ref uses the nearest adjacent pre-run boundary", async (context) => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-adjacent-boundary-test-")); + const workspace = path.join(tempRoot, "workspace"); + await mkdir(workspace); + await initializeFixtureRepository(workspace); + context.after(() => rm(tempRoot, { recursive: true, force: true })); + const { stdout: oldMainText } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: workspace, + }); + const oldMain = oldMainText.trim(); + + await execFileAsync("git", ["checkout", "-b", "pre-existing-source"], { cwd: workspace }); + await writeFile(path.join(workspace, "pre-existing-source.txt"), "pre-existing source\n"); + await execFileAsync("git", ["add", "pre-existing-source.txt"], { cwd: workspace }); + await execFileAsync("git", ["commit", "-m", "pre-existing intermediate commit"], { + cwd: workspace, + }); + const { stdout: intermediateText } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: workspace, + }); + const intermediate = intermediateText.trim(); + await execFileAsync("git", ["checkout", "-b", "temporary-worker-tip"], { cwd: workspace }); + await writeFile(path.join(workspace, "adjacent-worker.txt"), "worker change\n"); + await execFileAsync("git", ["add", "adjacent-worker.txt"], { cwd: workspace }); + await execFileAsync("git", ["commit", "-m", "worker commit after adjacent baseline"], { + cwd: workspace, + }); + const { stdout: workerText } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: workspace, + }); + const worker = workerText.trim(); + await execFileAsync("git", ["checkout", "main"], { cwd: workspace }); + await execFileAsync("git", ["branch", "-D", "temporary-worker-tip"], { cwd: workspace }); + await execFileAsync("git", ["update-ref", "refs/heads/main", worker, oldMain], { + cwd: workspace, + }); + + const selected = await closestExistingBase(workspace, worker, [oldMain, intermediate], { + preferredBase: oldMain, + }); + assert.equal(selected, intermediate, + "a more distant per-ref old tip must not beat the adjacent pre-run boundary"); + const commits = await committedDelta(workspace, { + head: oldMain, + headRef: "refs/heads/main", + refs: { "refs/heads/main": oldMain, "refs/heads/pre-existing-source": intermediate }, + fetchHeads: [], + }, { + head: worker, + headRef: "refs/heads/main", + refs: { "refs/heads/main": worker, "refs/heads/pre-existing-source": intermediate }, + fetchHeads: [], + }); + assert.equal(commits.newCommitCount, 1, commits.log); + assert.match(commits.log, /worker commit after adjacent baseline/u); + assert.doesNotMatch(commits.log, /pre-existing intermediate commit/u); + assert.match(commits.diffStat, /adjacent-worker\.txt/u); + assert.doesNotMatch(commits.diffStat, /pre-existing-source\.txt/u); +}); + +test("a merge prefers the moved ref's equally adjacent previous tip", async (context) => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-merge-boundary-test-")); + const workspace = path.join(tempRoot, "workspace"); + await mkdir(workspace); + await initializeFixtureRepository(workspace); + context.after(() => rm(tempRoot, { recursive: true, force: true })); + + await execFileAsync("git", ["checkout", "-b", "merge-side"], { cwd: workspace }); + await writeFile(path.join(workspace, "merge-side.txt"), "side history\n"); + await execFileAsync("git", ["add", "merge-side.txt"], { cwd: workspace }); + await execFileAsync("git", ["commit", "-m", "pre-existing merge side"], { cwd: workspace }); + const { stdout: sideText } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: workspace, + }); + const side = sideText.trim(); + await execFileAsync("git", ["checkout", "main"], { cwd: workspace }); + await writeFile(path.join(workspace, "main-before-merge.txt"), "main history\n"); + await execFileAsync("git", ["add", "main-before-merge.txt"], { cwd: workspace }); + await execFileAsync("git", ["commit", "-m", "pre-existing main tip"], { cwd: workspace }); + const { stdout: mainText } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: workspace, + }); + const main = mainText.trim(); + await execFileAsync("git", ["merge", "--no-ff", "merge-side", "-m", "worker merge commit"], { + cwd: workspace, + }); + const { stdout: mergeText } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: workspace, + }); + const merge = mergeText.trim(); + + const selected = await closestExistingBase(workspace, merge, [main, side], { + preferredBase: main, + }); + assert.equal(selected, main, + "the moved ref's old tip must win when both merge parents are equally adjacent"); + const commits = await committedDelta(workspace, { + head: main, + headRef: "refs/heads/main", + refs: { "refs/heads/main": main, "refs/heads/merge-side": side }, + fetchHeads: [], + }, { + head: merge, + headRef: "refs/heads/main", + refs: { "refs/heads/main": merge, "refs/heads/merge-side": side }, + fetchHeads: [], + }); + assert.equal(commits.newCommitCount, 1, commits.log); + assert.match(commits.log, /worker merge commit/u); + assert.match(commits.diffStat, /merge-side\.txt/u); + assert.doesNotMatch(commits.diffStat, /main-before-merge\.txt/u); +}); + +test("a non-ancestral force-moved ref uses the closest pre-run lineage", async (context) => { + const { workspace, client } = await makeHarness(context); + await execFileAsync("git", ["checkout", "-b", "force-target"], { cwd: workspace }); + await writeFile(path.join(workspace, "old-target-only.txt"), "old target history\n"); + await execFileAsync("git", ["add", "old-target-only.txt"], { cwd: workspace }); + await execFileAsync("git", ["commit", "-m", "pre-existing force target"], { cwd: workspace }); + await execFileAsync("git", ["checkout", "main"], { cwd: workspace }); + await execFileAsync("git", ["checkout", "-b", "source-lineage"], { cwd: workspace }); + await writeFile(path.join(workspace, "source-only.txt"), "pre-existing source history\n"); + await execFileAsync("git", ["add", "source-only.txt"], { cwd: workspace }); + await execFileAsync("git", ["commit", "-m", "pre-existing source commit"], { cwd: workspace }); + await execFileAsync("git", ["checkout", "main"], { cwd: workspace }); + + const response = await client.request("tools/call", taskArguments(workspace, { + name: "force-ref", forceRefFromExisting: true, fromBranch: "source-lineage", + refName: "refs/heads/force-target", writeFile: "forced-worker.txt", + commitMessage: "worker commit after force move", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out.error)); + assert.equal(out.commits.newCommitCount, 1, out.commits.log); + assert.match(out.commits.log, /worker commit after force move/u); + assert.doesNotMatch(out.commits.log, /pre-existing source commit/u); + assert.match(out.commits.diffStat, /forced-worker\.txt/u); + assert.doesNotMatch(out.commits.diffStat, /source-only\.txt/u, + "the old non-ancestral ref tip must not be used as the diff base"); + assert.doesNotMatch(out.commits.diffStat, /old-target-only\.txt/u); +}); + +test("linked worktrees sharing Git refs serialize across server processes", async (context) => { + const { tempRoot, workspace, configPath, client } = await makeHarness(context); + const linkedWorkspace = path.join(tempRoot, "linked-worktree"); + await execFileAsync("git", ["worktree", "add", "-b", "comparison-worktree", linkedWorkspace], { + cwd: workspace, + }); + assert.equal( + await canonicalGitCommonDirectory(workspace), + await canonicalGitCommonDirectory(linkedWorkspace), + ); + const secondClient = new McpClient(configPath); + try { + await secondClient.initialize(); + const eventFile = path.join(tempRoot, "linked-worktree-events.jsonl"); + const first = client.request("tools/call", taskArguments(workspace, { + name: "main-worktree", eventFile, delayMs: 800, writeFile: "main-only.txt", + })); + await waitFor(async () => (await events(eventFile)).some( + (item) => item.name === "main-worktree" && item.event === "start", + )); + const second = secondClient.request("tools/call", taskArguments(linkedWorkspace, { + name: "linked-worktree", eventFile, delayMs: 10, writeFile: "linked-only.txt", + })); + + const responses = await Promise.all([first, second]); + assert.ok(responses.every((response) => response.result.structuredContent.ok)); + assert.deepEqual((await events(eventFile)).map((item) => item.event + ":" + item.name), [ + "start:main-worktree", "end:main-worktree", + "start:linked-worktree", "end:linked-worktree", + ]); + } finally { + await secondClient.close(); + } +}); + +test("repository renames cannot create a second cross-process lease", { + skip: process.platform !== "linux" ? "Linux rename-stable directory handle fixture" : false, +}, async (context) => { + const { tempRoot, workspace, configPath, client } = await makeHarness(context); + const movedWorkspace = path.join(tempRoot, "renamed-workspace"); + const secondClient = new McpClient(configPath); + try { + await secondClient.initialize(); + const eventFile = path.join(tempRoot, "renamed-repository-events.jsonl"); + const first = client.request("tools/call", taskArguments(workspace, { + name: "pre-rename-holder", eventFile, delayMs: 3_000, + }), 17881); + await waitFor(async () => (await events(eventFile)).some( + (item) => item.name === "pre-rename-holder" && item.event === "start", + )); + await rename(workspace, movedWorkspace); + const second = secondClient.request("tools/call", taskArguments(movedWorkspace, { + name: "post-rename-waiter", eventFile, delayMs: 10, writeFile: "second-after-rename.txt", + }), 17882); + const early = await Promise.race([ + second.then(() => "completed"), + new Promise((resolve) => setTimeout(() => resolve("pending"), 300)), + ]); + assert.equal(early, "pending", "the moved repository must retain the original lease identity"); + await new Promise((resolve) => setTimeout(resolve, 900)); + assert.equal((await events(eventFile)).some( + (item) => item.name === "post-rename-waiter" && item.event === "start", + ), false, "the second process must reach the shared lease before the first worker exits"); + const [firstResponse, secondResponse] = await Promise.all([first, second]); + assert.match(firstResponse.error?.message ?? "", /git snapshot unreliable/iu, + "the old absolute worktree path should fail its post-run snapshot explicitly"); + assert.equal(secondResponse.result.structuredContent.ok, true, JSON.stringify(secondResponse)); + assert.deepEqual((await events(eventFile)).map((item) => item.event + ":" + item.name), [ + "start:pre-rename-holder", "end:pre-rename-holder", + "start:post-rename-waiter", "end:post-rename-waiter", + ]); + } finally { + await secondClient.close(); + } +}); + +test("persistent repository IDs keep lock keys stable across path and case changes", () => { + const repositoryId = "2a0e75f4-2f4e-4a78-9f03-2be3c9851fe5"; + const before = path.join("C:\\", "Work", "Repository", ".git"); + const after = path.join("D:\\", "moved", "repository", ".git"); + assert.equal(repositoryLockKey(before, repositoryId), repositoryLockKey(after, repositoryId)); + assert.notEqual(repositoryLockKey(before), repositoryLockKey(after)); +}); + + +test("a commit on the checked-out branch is reported exactly once", async (context) => { + const { workspace, client } = await makeHarness(context); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "current-commit", commitCurrent: true, + writeFile: "current.txt", commitMessage: "single worker commit", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out.error)); + assert.ok(out.commits, "HEAD and its branch both moved; a commits block must exist"); + assert.equal(out.commits.newCommitCount, 1, + "HEAD and refs/heads/main move across the same pair; count must not double"); + assert.equal(out.commits.log.split("single worker commit").length - 1, 1, + out.commits.log); + assert.match(out.commits.log, /HEAD, refs\/heads\/main/u, + "the deduplicated target carries both labels"); +}); + +test("one commit reached through a branch and tag is counted and logged once", async (context) => { + const { workspace, client } = await makeHarness(context); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "current-commit-with-tag", commitCurrent: true, + writeFile: "tagged-current.txt", commitMessage: "single tagged worker commit", + refName: "refs/tags/worker-tag", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out.error)); + assert.equal(out.commits.newCommitCount, 1); + assert.equal(out.commits.log.split("single tagged worker commit").length - 1, 1, + out.commits.log); + assert.match(out.commits.log, /HEAD, refs\/heads\/main, refs\/tags\/worker-tag/u, + "the unique commit retains every contributing ref label"); + assert.match(out.commits.diffStat, /HEAD, refs\/heads\/main, refs\/tags\/worker-tag/u, + "the identical commit range is emitted once with every contributing ref label"); +}); + +test("a new branch forked from a divergent branch diffs only its own commits", async (context) => { + const { workspace, client } = await makeHarness(context); + await execFileAsync("git", ["checkout", "-b", "divergent"], { cwd: workspace }); + await writeFile(path.join(workspace, "divergent.txt"), "pre-existing divergent file\n"); + await execFileAsync("git", ["add", "divergent.txt"], { cwd: workspace }); + await execFileAsync("git", ["commit", "-m", "divergent baseline commit"], { cwd: workspace }); + await execFileAsync("git", ["checkout", "main"], { cwd: workspace }); + + const response = await client.request("tools/call", taskArguments(workspace, { + name: "fork-worker", newBranchFromExisting: true, + fromBranch: "divergent", branchName: "forked-work", + writeFile: "fork.txt", commitMessage: "forked worker commit", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out.error)); + assert.match(out.commits.log, /forked worker commit/u); + assert.doesNotMatch(out.commits.log, /divergent baseline commit/u); + assert.doesNotMatch(out.commits.diffStat, /divergent\.txt/u, + "the diff base is the fork point, not the original HEAD"); + assert.match(out.commits.diffStat, /fork\.txt/u); +}); + +test("new-branch attribution considers baselines beyond the first 256 tips", { + skip: process.platform === "win32", +}, async (context) => { + const { workspace, client } = await makeHarness(context); + const { stdout: mainTree } = await execFileAsync("git", ["rev-parse", "HEAD^{tree}"], { cwd: workspace }); + for (let index = 0; index < 256; index += 1) { + const { stdout: oid } = await execFileAsync( + "git", + ["commit-tree", mainTree.trim(), "-p", "HEAD", "-m", "filler " + String(index)], + { cwd: workspace }, + ); + await execFileAsync( + "git", ["update-ref", `refs/heads/a-filler-${String(index).padStart(3, "0")}`, oid.trim()], + { cwd: workspace }, + ); + } + await execFileAsync("git", ["checkout", "-b", "zzz-source"], { cwd: workspace }); + await writeFile(path.join(workspace, "late-source.txt"), "pre-existing late baseline\n"); + await execFileAsync("git", ["add", "late-source.txt"], { cwd: workspace }); + await execFileAsync("git", ["commit", "-m", "late source baseline"], { cwd: workspace }); + await execFileAsync("git", ["checkout", "main"], { cwd: workspace }); + + const response = await client.request("tools/call", taskArguments(workspace, { + name: "late-baseline-worker", newBranchFromExisting: true, + fromBranch: "zzz-source", branchName: "late-baseline-work", + writeFile: "late-worker.txt", commitMessage: "late baseline worker commit", + }, { timeoutMs: 120_000 })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out.error)); + assert.match(out.commits.log, /late baseline worker commit/u); + assert.doesNotMatch(out.commits.diffStat, /late-source\.txt/u, + "the source tip after 256 other baselines remains the selected diff base"); + assert.match(out.commits.diffStat, /late-worker\.txt/u); +}); + +test("direct remote-tracking ref writes do not hide worker-created commits", async (context) => { + const { workspace, client } = await makeHarness(context); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "fetch-then-work", fetchAndCommit: true, + branchName: "fetched-work", writeFile: "worker-after-fetch.txt", + commitMessage: "worker commit after fetch", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out.error)); + assert.equal(out.commits.newCommitCount, 2, out.commits.log); + assert.match(out.commits.log, /worker commit after fetch/u); + assert.match(out.commits.log, /fetched upstream commit/u); + assert.match(out.commits.diffStat, /upstream\.txt/u); + assert.match(out.commits.diffStat, /worker-after-fetch\.txt/u); +}); + +test("provenance reads stay bound to the originally opened regular file", async (context) => { + const fixture = await makeTraceFixture(context); + await unlink(fixture.tracePath); + await writeFile(fixture.tracePath, ""); + await assert.rejects( + readBackendGitProvenance(fixture, pluginRoot), + /no longer identifies the original regular file/iu, + ); +}); + +test("bounded regular-file reads reject oversized FETCH_HEAD-style input", async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), "cli-agent-bounded-fetch-head-test-")); + context.after(() => rm(root, { recursive: true, force: true })); + const file = path.join(root, "FETCH_HEAD"); + await writeFile(file, "12345678"); + assert.equal(await readBoundedRegularFile(file, 8), "12345678"); + await writeFile(file, "123456789"); + await assert.rejects(readBoundedRegularFile(file, 8), /capture limit/iu); +}); + +test("bounded FETCH_HEAD-style reads reject FIFO and device symlinks without hanging", { + skip: process.platform !== "linux", +}, async () => { + const source = [ + "import {execFileSync} from 'node:child_process'", + "import {mkdtemp,rm,symlink} from 'node:fs/promises'", + "import os from 'node:os'", + "import path from 'node:path'", + `import {readBoundedRegularFile} from ${JSON.stringify(pathToFileURL(serverPath).href)}`, + "const mode=process.argv[1]", + "const root=await mkdtemp(path.join(os.tmpdir(),'fetch-head-replacement-child-'))", + "const file=path.join(root,'FETCH_HEAD')", + "if(mode==='fifo')execFileSync('mkfifo',[file]);else await symlink('/dev/zero',file)", + "let rejected=false", + "try{await readBoundedRegularFile(file,8)}catch{rejected=true}", + "await rm(root,{recursive:true,force:true})", + "if(!rejected)process.exit(3)", + ].join(";"); + for (const mode of ["fifo", "device"]) { + await execFileAsync(process.execPath, [ + "--max-old-space-size=32", "--input-type=module", "-e", source, mode, + ], { timeout: 3_000, maxBuffer: 1_000_000 }); + } +}); + +test("Windows Trace2 worktree matching preserves case-sensitive path identity", { + skip: process.platform !== "win32", +}, async (context) => { + const fixture = await makeTraceFixture(context); + const target = path.join(fixture.root, "CaseSensitiveRepository"); + const sibling = path.join(fixture.root, "casesensitiverepository"); + const trace = [ + { event: "start", sid: "case-session", argv: ["git", "fetch"] }, + { event: "def_repo", sid: "case-session", worktree: sibling }, + { event: "exit", sid: "case-session", code: 0 }, + ].map((event) => JSON.stringify(event)).join("\n") + "\n"; + await fixture.handle.write(Buffer.from(trace), 0, Buffer.byteLength(trace), 0); + assert.deepEqual(await readBackendGitProvenance(fixture, target), { + uncertain: false, sawFetch: false, + }); +}); + +test("provenance reads reject FIFO and device symlink replacements without hanging", { + skip: process.platform !== "linux", +}, async () => { + const source = [ + "import {execFileSync} from 'node:child_process'", + "import {mkdtemp,open,rm,symlink,unlink} from 'node:fs/promises'", + "import os from 'node:os'", + "import path from 'node:path'", + `import {readBackendGitProvenance} from ${JSON.stringify(pathToFileURL(serverPath).href)}`, + "const mode=process.argv[1]", + "const root=await mkdtemp(path.join(os.tmpdir(),'trace-replacement-child-'))", + "const tracePath=path.join(root,'git.trace')", + "const handle=await open(tracePath,'wx+',0o600)", + "const identity=await handle.stat({bigint:true})", + "await unlink(tracePath)", + "if(mode==='fifo')execFileSync('mkfifo',[tracePath]);else await symlink('/dev/zero',tracePath)", + "let rejected=false", + "try{await readBackendGitProvenance({tracePath,handle,identity},process.cwd())}catch{rejected=true}", + "await handle.close();await rm(root,{recursive:true,force:true})", + "if(!rejected)process.exit(3)", + ].join(";"); + for (const mode of ["fifo", "device"]) { + await execFileAsync(process.execPath, [ + "--max-old-space-size=32", "--input-type=module", "-e", source, mode, + ], { timeout: 3_000, maxBuffer: 1_000_000 }); + } +}); + +test("provenance reads are bounded and interruptible", async (context) => { + const oversized = await makeTraceFixture(context); + await oversized.handle.truncate(5_000_001); + await assert.rejects( + readBackendGitProvenance(oversized, pluginRoot), + /capture limit/iu, + ); + + const cancelledFixture = await makeTraceFixture(context); + let readStartedResolve; + const readStarted = new Promise((resolve) => { readStartedResolve = resolve; }); + const stalledHandle = { + stat: (...args) => cancelledFixture.handle.stat(...args), + read: async () => { + readStartedResolve(); + return await new Promise(() => {}); + }, + }; + const cancel = testCancellation(); + const cancelledRead = readBackendGitProvenance({ + ...cancelledFixture, handle: stalledHandle, + }, pluginRoot, { cancel }); + await readStarted; + const cancelledAt = Date.now(); + cancel.cancel(); + await assert.rejects(cancelledRead, /cancelled/iu); + assert.ok(Date.now() - cancelledAt < 1_000); + + let expiredCalls = 0; + const expiredHandle = { + stat: async () => { expiredCalls += 1; return cancelledFixture.identity; }, + read: async () => { expiredCalls += 1; return { bytesRead: 0 }; }, + }; + await assert.rejects(readBackendGitProvenance({ + ...cancelledFixture, handle: expiredHandle, + }, pluginRoot, { deadline: Date.now() - 1 }), /deadline/iu); + assert.equal(expiredCalls, 0, "an already-expired read must not start filesystem I/O"); + + const denseFixture = await makeTraceFixture(context); + const denseTrace = Buffer.from("{}\n".repeat(1_600_000)); + await denseFixture.handle.write(denseTrace, 0, denseTrace.length, 0); + const parsingAt = Date.now(); + await assert.rejects(readBackendGitProvenance( + denseFixture, pluginRoot, { deadline: parsingAt + 100 }, + ), /deadline/iu); + assert.ok(Date.now() - parsingAt < 1_500, + "parsing a dense bounded trace must continue to observe the request deadline"); +}); + +test("a replaced backend provenance path degrades attribution and is cleaned up", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const traceRootFile = path.join(tempRoot, "trace-root.txt"); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "replace-provenance", + replaceTraceThenWrite: true, + traceRootFile, + writeFile: "replacement-provenance.txt", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out)); + assert.ok(out.git.changedFiles.includes("replacement-provenance.txt"), JSON.stringify(out.git)); + assert.equal(out.commits.attributionUnavailable, true, JSON.stringify(out.commits)); + assert.equal(out.commits.newCommitCount, null); + const traceRoot = await readFile(traceRootFile, "utf8"); + await assert.rejects(access(traceRoot), /ENOENT/u, + "the controlled provenance directory should be removed after the worker settles"); +}); + +test("a workspace fetch makes commit attribution explicitly unavailable", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const requestDirectory = path.join(workspace, "nested-request-directory"); + await mkdir(requestDirectory); + const upstream = path.join(tempRoot, "local-ref-upstream"); + await mkdir(upstream); + await execFileAsync("git", ["init"], { cwd: upstream }); + await execFileAsync("git", ["config", "user.name", "Upstream Fixture"], { cwd: upstream }); + await execFileAsync("git", ["config", "user.email", "upstream@example.invalid"], { cwd: upstream }); + await writeFile(path.join(upstream, "external-local-ref.txt"), "external history\n"); + await execFileAsync("git", ["add", "external-local-ref.txt"], { cwd: upstream }); + await execFileAsync("git", ["commit", "-m", "externally fetched local-ref commit"], { cwd: upstream }); + await execFileAsync("git", ["branch", "-M", "topic"], { cwd: upstream }); + + const response = await client.request("tools/call", taskArguments(requestDirectory, { + name: "fetch-local-ref-then-work", fetchIntoLocalRef: true, remotePath: upstream, + importedRef: "refs/heads/imported-upstream", branchName: "local-fetch-work", + writeFile: "worker-after-local-fetch.txt", commitMessage: "worker commit after local-ref fetch", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out)); + assert.equal(out.commits.attributionUnavailable, true, JSON.stringify(out.commits)); + assert.equal(out.commits.newCommitCount, null); + assert.equal(out.commits.log, ""); + assert.equal(out.commits.diffStat, null); + assert.ok(out.commits.refsChanged.some((item) => item.ref === "refs/heads/imported-upstream"), + JSON.stringify(out.commits.refsChanged)); +}); + +test("successive fetches are detected even after FETCH_HEAD is overwritten", async (context) => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-successive-fetch-test-")); + const workspace = path.join(tempRoot, "workspace"); + await mkdir(workspace); + await initializeFixtureRepository(workspace); + context.after(() => rm(tempRoot, { recursive: true, force: true })); + const makeUpstream = async (name, fileName, message) => { + const upstream = path.join(tempRoot, name); + await mkdir(upstream); + await execFileAsync("git", ["init"], { cwd: upstream }); + await execFileAsync("git", ["config", "user.name", "Upstream Fixture"], { cwd: upstream }); + await execFileAsync("git", ["config", "user.email", "upstream@example.invalid"], { cwd: upstream }); + await writeFile(path.join(upstream, fileName), message + "\n"); + await execFileAsync("git", ["add", fileName], { cwd: upstream }); + await execFileAsync("git", ["commit", "-m", message], { cwd: upstream }); + await execFileAsync("git", ["branch", "-M", "topic"], { cwd: upstream }); + const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: upstream }); + return { upstream, oid: stdout.trim() }; + }; + const first = await makeUpstream("multi-fetch-a", "external-a.txt", "externally fetched A"); + const second = await makeUpstream("multi-fetch-b", "external-b.txt", "externally fetched B"); + const third = await makeUpstream("multi-fetch-c", "external-c.txt", "externally fetched C"); + // Preload A and B without retaining a ref/FETCH_HEAD baseline. Both in-run + // fetches are up to date (no packet want); A also uses no destination and + // suppresses FETCH_HEAD, so only the Trace2-bound explicit source + // advertisement can prove its tip is external. + await execFileAsync("git", ["fetch", first.upstream, "refs/heads/topic"], { cwd: workspace }); + await execFileAsync("git", ["fetch", second.upstream, "refs/heads/topic"], { cwd: workspace }); + await rm(path.join(workspace, ".git", "FETCH_HEAD"), { force: true }); + + const tracePath = path.join(tempRoot, "successive-fetch.trace2"); + const traceHandle = await open(tracePath, "wx+", 0o600); + context.after(() => traceHandle.close().catch(() => {})); + const traceIdentity = await traceHandle.stat({ bigint: true }); + const env = backendGitProvenanceEnvironment(tracePath); + await execFileAsync("git", [ + "-c", "protocol.version=0", "fetch", "--no-write-fetch-head", + first.upstream, "refs/heads/topic", + ], { cwd: workspace, env }); + await execFileAsync("git", ["update-ref", "refs/custom/imported-a", first.oid], { cwd: workspace, env }); + await execFileAsync("git", [ + "fetch", second.upstream, "refs/heads/topic:refs/custom/imported-b", + ], { cwd: workspace, env }); + await execFileAsync("git", ["fetch", third.upstream, "refs/heads/topic"], { cwd: workspace, env }); + await execFileAsync("git", ["update-ref", "refs/custom/imported-c", third.oid], { cwd: workspace, env }); + + const provenance = await readBackendGitProvenance({ + tracePath, handle: traceHandle, identity: traceIdentity, + }, workspace); + assert.equal(provenance.sawFetch, true); + assert.equal(provenance.uncertain, true, + "any successful target-repository fetch makes commit attribution unavailable"); + const fetchHead = await readFile(path.join(workspace, ".git", "FETCH_HEAD"), "utf8"); + assert.match(fetchHead, new RegExp(third.oid, "u")); + assert.doesNotMatch(fetchHead, new RegExp(first.oid + "|" + second.oid, "u"), + "the fixture must demonstrate that later fetches overwrote both earlier tips"); + for (const ref of ["refs/custom/imported-a", "refs/custom/imported-b", "refs/custom/imported-c"]) { + const { stdout } = await execFileAsync("git", ["rev-parse", "--verify", ref], { cwd: workspace }); + assert.match(stdout, /^[0-9a-f]{40,64}\s*$/u); + } +}); + +test("a partially successful nonzero fetch makes provenance uncertain", async (context) => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-partial-fetch-test-")); + const workspace = path.join(tempRoot, "workspace"); + const upstream = path.join(tempRoot, "upstream"); + await mkdir(workspace); + await mkdir(upstream); + await initializeFixtureRepository(workspace); + await initializeFixtureRepository(upstream); + context.after(() => rm(tempRoot, { recursive: true, force: true })); + + await execFileAsync("git", ["checkout", "-b", "good"], { cwd: upstream }); + await writeFile(path.join(upstream, "good.txt"), "good remote ref\n"); + await execFileAsync("git", ["add", "good.txt"], { cwd: upstream }); + await execFileAsync("git", ["commit", "-m", "good remote update"], { cwd: upstream }); + const { stdout: goodOidRaw } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: upstream }); + const goodOid = goodOidRaw.trim(); + await execFileAsync("git", ["checkout", "main"], { cwd: upstream }); + await execFileAsync("git", ["checkout", "-b", "rejected"], { cwd: upstream }); + await writeFile(path.join(upstream, "rejected.txt"), "rejected remote ref\n"); + await execFileAsync("git", ["add", "rejected.txt"], { cwd: upstream }); + await execFileAsync("git", ["commit", "-m", "rejected remote update"], { cwd: upstream }); + + await execFileAsync("git", ["checkout", "-b", "local-divergent"], { cwd: workspace }); + await writeFile(path.join(workspace, "local-divergent.txt"), "local divergent history\n"); + await execFileAsync("git", ["add", "local-divergent.txt"], { cwd: workspace }); + await execFileAsync("git", ["commit", "-m", "local divergent destination"], { cwd: workspace }); + const { stdout: localOidRaw } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workspace }); + const localOid = localOidRaw.trim(); + await execFileAsync("git", ["checkout", "main"], { cwd: workspace }); + await execFileAsync("git", ["branch", "-D", "local-divergent"], { cwd: workspace }); + await execFileAsync("git", ["update-ref", "refs/custom/rejected", localOid], { cwd: workspace }); + + const tracePath = path.join(tempRoot, "partial-fetch.trace2"); + const traceHandle = await open(tracePath, "wx+", 0o600); + context.after(() => traceHandle.close().catch(() => {})); + const traceIdentity = await traceHandle.stat({ bigint: true }); + let fetchFailure = null; + try { + await execFileAsync("git", [ + "fetch", upstream, + "refs/heads/good:refs/custom/imported-good", + "refs/heads/rejected:refs/custom/rejected", + ], { cwd: workspace, env: backendGitProvenanceEnvironment(tracePath) }); + } catch (error) { + fetchFailure = error; + } + assert.ok(fetchFailure, "one rejected destination must make fetch nonzero"); + assert.equal(fetchFailure.code, 1, String(fetchFailure.stderr ?? fetchFailure)); + const { stdout: importedGood } = await execFileAsync( + "git", ["rev-parse", "refs/custom/imported-good"], { cwd: workspace }, + ); + const { stdout: rejectedAfter } = await execFileAsync( + "git", ["rev-parse", "refs/custom/rejected"], { cwd: workspace }, + ); + assert.equal(importedGood.trim(), goodOid, "the successful ref update proves partial mutation"); + assert.equal(rejectedAfter.trim(), localOid, "the non-fast-forward destination must stay unchanged"); + const provenance = await readBackendGitProvenance({ + tracePath, handle: traceHandle, identity: traceIdentity, + }, workspace); + assert.deepEqual(provenance, { uncertain: true, sawFetch: true }); +}); + +test("pull makes commit attribution explicitly unavailable", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const upstream = path.join(tempRoot, "pull-upstream"); + await execFileAsync("git", ["clone", workspace, upstream], { cwd: tempRoot }); + await execFileAsync("git", ["config", "user.name", "Upstream Fixture"], { cwd: upstream }); + await execFileAsync("git", ["config", "user.email", "upstream@example.invalid"], { cwd: upstream }); + await writeFile(path.join(upstream, "external-pull.txt"), "external\n"); + await execFileAsync("git", ["add", "external-pull.txt"], { cwd: upstream }); + await execFileAsync("git", ["commit", "-m", "external pull parent"], { cwd: upstream }); + await execFileAsync("git", ["branch", "-M", "topic"], { cwd: upstream }); + await writeFile(path.join(workspace, "local-before-pull.txt"), "local\n"); + await execFileAsync("git", ["add", "local-before-pull.txt"], { cwd: workspace }); + await execFileAsync("git", ["commit", "-m", "local baseline before pull"], { cwd: workspace }); + + const response = await client.request("tools/call", taskArguments(workspace, { + name: "pull-with-local-merge", pullNoRebase: true, remotePath: upstream, + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out)); + assert.equal(out.commits.attributionUnavailable, true, JSON.stringify(out.commits)); + assert.equal(out.commits.newCommitCount, null); + assert.equal(out.commits.log, ""); +}); + +test("a fetch in another repository does not disable target attribution", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const otherRepository = path.join(tempRoot, "other-repository"); + const upstream = path.join(tempRoot, "other-upstream"); + await mkdir(otherRepository); + await mkdir(upstream); + for (const repository of [otherRepository, upstream]) { + await execFileAsync("git", ["init"], { cwd: repository }); + await execFileAsync("git", ["config", "user.name", "Other Fixture"], { cwd: repository }); + await execFileAsync("git", ["config", "user.email", "other@example.invalid"], { cwd: repository }); + await writeFile(path.join(repository, "base.txt"), repository + "\n"); + await execFileAsync("git", ["add", "base.txt"], { cwd: repository }); + await execFileAsync("git", ["commit", "-m", "other baseline"], { cwd: repository }); + } + await execFileAsync("git", ["branch", "-M", "topic"], { cwd: upstream }); + + const response = await client.request("tools/call", taskArguments(workspace, { + name: "cross-repository-fetch", fetchOtherRepositoryThenCommit: true, + otherRepository, remotePath: upstream, + writeFile: "target-after-other-fetch.txt", commitMessage: "target commit after other fetch", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out)); + assert.equal(out.commits.attributionUnavailable, undefined, JSON.stringify(out.commits)); + assert.equal(out.commits.newCommitCount, 1, out.commits.log); + assert.match(out.commits.log, /target commit after other fetch/u); + assert.match(out.commits.diffStat, /target-after-other-fetch\.txt/u); +}); + +test("fetch-only runs disclose unavailable attribution and retain worktree files", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const upstream = path.join(tempRoot, "fetch-only-upstream"); + await mkdir(upstream); + await execFileAsync("git", ["init"], { cwd: upstream }); + await execFileAsync("git", ["config", "user.name", "Upstream Fixture"], { cwd: upstream }); + await execFileAsync("git", ["config", "user.email", "upstream@example.invalid"], { cwd: upstream }); + await writeFile(path.join(upstream, "external.txt"), "external\n"); + await execFileAsync("git", ["add", "external.txt"], { cwd: upstream }); + await execFileAsync("git", ["commit", "-m", "external fetch-only commit"], { cwd: upstream }); + await execFileAsync("git", ["branch", "-M", "topic"], { cwd: upstream }); + + const response = await client.request("tools/call", taskArguments(workspace, { + name: "fetch-only", fetchOnlyThenWrite: true, remotePath: upstream, + writeFile: "after-fetch-only.txt", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out)); + assert.equal(out.commits.attributionUnavailable, true, JSON.stringify(out.commits)); + assert.deepEqual(out.commits.refsChanged, []); + assert.equal(out.commits.newCommitCount, null); + assert.ok(out.git.changedFiles.includes("after-fetch-only.txt"), JSON.stringify(out.git.changedFiles)); +}); + +test("malformed provenance cannot discard a completed worktree snapshot", async (context) => { + const { workspace, client } = await makeHarness(context); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "malformed-provenance", corruptTraceThenWrite: true, + writeFile: "after-malformed-provenance.txt", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out)); + assert.equal(out.commits.attributionUnavailable, true, JSON.stringify(out.commits)); + assert.equal(out.commits.newCommitCount, null); + assert.ok(out.git.changedFiles.includes("after-malformed-provenance.txt"), + JSON.stringify(out.git.changedFiles)); +}); + +test("direct tag writes do not hide worker-created commits", async (context) => { + const { workspace, client } = await makeHarness(context); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "fetch-tag-then-work", fetchAndCommit: true, fetchTagOnly: true, + branchName: "fetched-tag-work", writeFile: "worker-after-tag.txt", + commitMessage: "worker commit after fetched tag", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out.error)); + assert.equal(out.commits.newCommitCount, 2, out.commits.log); + assert.match(out.commits.log, /worker commit after fetched tag/u); + assert.match(out.commits.log, /fetched upstream commit/u); + assert.match(out.commits.diffStat, /upstream\.txt/u); + assert.match(out.commits.diffStat, /worker-after-tag\.txt/u); +}); + +test("direct prefetch ref writes do not hide worker-created commits", async (context) => { + const { workspace, client } = await makeHarness(context); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "prefetch-then-work", fetchAndCommit: true, fetchPrefetch: true, + branchName: "prefetched-work", writeFile: "worker-after-prefetch.txt", + commitMessage: "worker commit after prefetch", + })); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out.error)); + assert.equal(out.commits.newCommitCount, 2, out.commits.log); + assert.match(out.commits.log, /worker commit after prefetch/u); + assert.match(out.commits.log, /fetched upstream commit/u); + assert.match(out.commits.diffStat, /upstream\.txt/u); + assert.match(out.commits.diffStat, /worker-after-prefetch\.txt/u); +}); + +test("workspace lock metadata is absent from mirrored repository refs", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const mirror = path.join(tempRoot, "mirror.git"); + await execFileAsync("git", ["init", "--bare", mirror], { cwd: tempRoot }); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "mirror", mirrorPush: true, remotePath: mirror, + })); + const out = response.result.structuredContent; + if (!out.ok) { + assert.match(out.error, /process tree could not be confirmed terminated/iu); + assert.ok(out.quarantinePath, JSON.stringify(out)); + context.after(() => rm(out.quarantinePath, { recursive: true, force: true })); + } + const { stdout: mirroredRefs } = await execFileAsync( + "git", ["for-each-ref", "--format=%(refname)"], { cwd: mirror }, + ); + assert.match(mirroredRefs, /refs\/heads\/main/u); + assert.doesNotMatch(mirroredRefs, /cli-agent-bridge|workspace-locks/iu, + "coordination metadata must live outside the repository ref namespace"); + const { stdout: localInternalRefs } = await execFileAsync( + "git", ["for-each-ref", "--format=%(refname)", "refs/cli-agent-bridge"], { cwd: workspace }, + ); + assert.equal(localInternalRefs, ""); +}); + +test("list_backends can be cancelled while a version probe hangs", async (context) => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-bridge-test-")); + context.after(async () => { await rm(tempRoot, { recursive: true, force: true }); }); + const hangScript = path.join(tempRoot, "hang-version.mjs"); + const startedFile = path.join(tempRoot, "version-probe-started.txt"); + await writeFile(hangScript, + "import{writeFileSync}from'node:fs';" + + `writeFileSync(${JSON.stringify(startedFile)},'started\\n');` + + "setTimeout(()=>{},60000);\n"); + let hangingCommand; + if (process.platform === "win32") { + hangingCommand = path.join(tempRoot, "hang-version.cmd"); + // Match the narrowly parsed npm-shim shape so the managed Windows runner + // can safely forward --version to the real Node entry without cmd quoting. + await writeFile(hangingCommand, [ + "@ECHO off", + "SETLOCAL", + "SET dp0=%~dp0", + 'IF EXIST "%dp0%\\node.exe" (', + ' SET "_prog=%dp0%\\node.exe"', + ") ELSE (", + ' SET "_prog=node"', + ")", + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\hang-version.mjs" %*', + "", + ].join("\r\n")); + } else { + hangingCommand = path.join(tempRoot, "hang-version"); + await writeFile(hangingCommand, + `#!${process.execPath}\nimport ${JSON.stringify(pathToFileURL(hangScript).href)};\n`); + await chmod(hangingCommand, 0o755); + } + const configPath = path.join(tempRoot, "backends.json"); + await writeFile(configPath, JSON.stringify({ + backends: { + fast: { + label: "Fast backend", + command: process.execPath, + buildArgs: [""], + resumeArgs: null, + experimental: false, + }, + hang: { + label: "Hanging backend", + command: hangingCommand, + buildArgs: [""], + resumeArgs: null, + experimental: false, + }, + }, + })); + const client = new McpClient(configPath); + await client.initialize(); + context.after(async () => { await client.close(); }); + const started = Date.now(); + const responsePromise = client.request("tools/call", { name: "list_backends", arguments: {} }, 4242); + await waitFor(async () => { + try { return (await readFile(startedFile, "utf8")).includes("started"); } catch { return false; } + }); + client.notify("notifications/cancelled", { requestId: 4242 }); + const response = await responsePromise; + const elapsed = Date.now() - started; + assert.ok(elapsed < 12_000, "cancellation must terminate the probe well before the 15s timeout"); + assert.equal(response.result, undefined, JSON.stringify(response)); + assert.equal(response.error?.code, -32800, JSON.stringify(response)); + assert.match(response.error?.message ?? "", /list_backends cancelled/iu); +}); + +test("missing backend commands fail before workspace launch", { + skip: process.platform === "win32", +}, async (context) => { + const { workspace, configPath, client } = await makeHarness(context); + await writeFile(configPath, JSON.stringify({ + backends: { + missing: { + label: "Missing backend", + command: "cli-agent-bridge-command-that-does-not-exist", + buildArgs: [""], + resumeArgs: null, + experimental: false, + }, + }, + })); + const response = await client.request("tools/call", { + name: "delegate_task", + arguments: { backend: "missing", task: "run", workspacePath: workspace }, + }); + const out = response.result.structuredContent; + assert.equal(out.ok, false); + assert.match(out.error, /command was not found or is not executable/iu); + assert.doesNotMatch(out.error, /exited with code null/iu); +}); + +test("PowerShell shim runner fails closed for a missing backend", { + skip: process.platform !== "win32", +}, async () => { + const runner = path.join(pluginRoot, "ps1-runner.ps1"); + let failure = null; + try { + await execFileAsync("powershell.exe", [ + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", runner, + "cli-agent-bridge-command-that-does-not-exist", "--version", + ]); + } catch (error) { + failure = error; + } + assert.ok(failure, "missing backend must return a non-zero exit code"); + assert.notEqual(failure.code, 0); +}); + +test("PowerShell shim runner preserves native backend exit codes", { + skip: process.platform !== "win32", +}, async () => { + const runner = path.join(pluginRoot, "ps1-runner.ps1"); + let failure = null; + try { + await execFileAsync("powershell.exe", [ + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", runner, + "cmd.exe", "/d", "/c", "exit", "37", + ]); + } catch (error) { + failure = error; + } + assert.ok(failure); + assert.equal(failure.code, 37); +}); + +test("a worktree root ending in whitespace is canonicalized without trimming it", async (context) => { + if (process.platform === "win32") return; // NTFS forbids trailing spaces in names + const { tempRoot, client } = await makeHarness(context); + const spaced = path.join(tempRoot, "workspace "); + await mkdir(spaced); + await execFileAsync("git", ["init", "-b", "main"], { cwd: spaced }); + await execFileAsync("git", ["config", "user.name", "Bridge Test"], { cwd: spaced }); + await execFileAsync("git", ["config", "user.email", "bridge-test@example.invalid"], { cwd: spaced }); + await writeFile(path.join(spaced, "baseline.txt"), "baseline\n"); + await execFileAsync("git", ["add", "baseline.txt"], { cwd: spaced }); + await execFileAsync("git", ["commit", "-m", "baseline"], { cwd: spaced }); + const response = await client.request("tools/call", { + name: "workspace_status", + arguments: { workspacePath: spaced }, + }); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out.error ?? out)); + assert.ok(out.worktreeRoot.endsWith(" "), + "the trailing space is part of the canonical root: " + JSON.stringify(out.worktreeRoot)); +}); + +test("a separate Git common directory ending in whitespace is preserved", async (context) => { + if (process.platform === "win32") return; // NTFS forbids trailing spaces in names + const { tempRoot, client } = await makeHarness(context); + const commonDirectory = path.join(tempRoot, "separate-git "); + const worktree = path.join(tempRoot, "separate-worktree"); + await execFileAsync("git", [ + "init", "-b", "main", "--separate-git-dir", commonDirectory, worktree, + ], { cwd: tempRoot }); + await execFileAsync("git", ["config", "user.name", "Bridge Test"], { cwd: worktree }); + await execFileAsync("git", ["config", "user.email", "bridge-test@example.invalid"], { cwd: worktree }); + await writeFile(path.join(worktree, "baseline.txt"), "baseline\n"); + await execFileAsync("git", ["add", "baseline.txt"], { cwd: worktree }); + await execFileAsync("git", ["commit", "-m", "baseline"], { cwd: worktree }); + const response = await client.request("tools/call", { + name: "workspace_status", + arguments: { workspacePath: worktree }, + }); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out.error ?? out)); + assert.equal(await canonicalGitCommonDirectory(worktree), await realpath(commonDirectory)); +}); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs new file mode 100644 index 0000000..f8c2220 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs @@ -0,0 +1,957 @@ +import assert from "node:assert/strict"; +import { execFile, execFileSync } from "node:child_process"; +import { access, chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { promisify } from "node:util"; + +import { + acquireGitWorkspaceLock, + localHostIdentity, + tryAcquireGitWorkspaceLock, + workspaceHistoryRef, + workspaceLockRef, + workspaceRecoveryRef, + WorkspaceLockCancelledError, + WorkspaceLockDeadlineError, +} from "../workspace-lock.mjs"; + +const execFileAsync = promisify(execFile); + +function cancellationToken() { + const listeners = new Set(); + let resolvePromise; + return { + cancelled: false, + promise: new Promise((resolve) => { resolvePromise = resolve; }), + subscribe(listener) { + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + cancel() { + if (this.cancelled) return; + this.cancelled = true; + resolvePromise(); + for (const listener of [...listeners]) listener(); + listeners.clear(); + }, + }; +} + +async function waitForFile(file, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await access(file); + return; + } catch { /* keep polling */ } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("timed out waiting for " + file); +} + +async function git(cwd, args, input = undefined) { + const result = await execFileAsync("git", args, { + cwd, + input, + encoding: "utf8", + }); + return result.stdout.trim(); +} + +async function makeRepo(context) { + const root = await mkdtemp(path.join(os.tmpdir(), "cli-agent-lock-test-")); + const repo = path.join(root, "repo"); + await mkdir(repo); + await git(repo, ["init", "-b", "main"]); + context.after(() => rm(root, { recursive: true, force: true })); + return repo; +} + +async function installOwner(repo, ref, owner) { + const oid = execFileSync("git", ["hash-object", "-w", "--stdin"], { + cwd: repo, + input: JSON.stringify(owner) + "\n", + encoding: "utf8", + }).trim(); + await git(repo, ["update-ref", ref, oid]); + return oid; +} + +test("a stale same-host lock is replaced only when its owner is confirmed dead", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const ref = workspaceLockRef(key); + const now = Date.now(); + const oldOid = await installOwner(repo, ref, { + version: 1, + token: "dead-owner", + hostIdentity: localHostIdentity(), + ownerPid: 12345, + workerState: "idle", + workerPid: null, + acquiredAt: now - 60_000, + heartbeatAt: now - 60_000, + }); + + const result = await tryAcquireGitWorkspaceLock({ + cwd: repo, + key, + now, + staleMs: 1_000, + heartbeatMs: 60_000, + processProbe: () => "dead", + }); + assert.equal(result.acquired, true); + const newOid = await git(repo, ["rev-parse", ref]); + assert.notEqual(newOid, oldOid, "stale-owner takeover must CAS the ref to a new owner blob"); + await result.lease.release(); + await assert.rejects(execFileAsync("git", ["rev-parse", "--verify", ref], { cwd: repo }), /Command failed/u); +}); + +test("a stale lock with a live owner is never stolen", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const ref = workspaceLockRef(key); + const now = Date.now(); + const oldOid = await installOwner(repo, ref, { + version: 1, + token: "live-owner", + hostIdentity: localHostIdentity(), + ownerPid: 23456, + workerState: "idle", + workerPid: null, + acquiredAt: now - 60_000, + heartbeatAt: now - 60_000, + }); + + const result = await tryAcquireGitWorkspaceLock({ + cwd: repo, + key, + now, + staleMs: 1_000, + processProbe: () => "alive", + }); + assert.deepEqual(result, { acquired: false, reason: "held" }); + assert.equal(await git(repo, ["rev-parse", ref]), oldOid); +}); + +test("a stale owner PID reused by another process does not pin an idle lease", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const ref = workspaceLockRef(key); + const now = Date.now(); + await installOwner(repo, ref, { + version: 1, + token: "reused-owner-pid", + hostIdentity: localHostIdentity(), + ownerPid: 12345, + ownerIdentity: "original-start", + workerState: "idle", + workerPid: null, + acquiredAt: now - 60_000, + heartbeatAt: now - 60_000, + }); + const result = await tryAcquireGitWorkspaceLock({ + cwd: repo, + key, + now, + staleMs: 1_000, + heartbeatMs: 60_000, + processProbe: () => "alive", + processIdentityProbe: () => "reused-start", + }); + assert.equal(result.acquired, true, + "a live but differently-started PID is not the original stale owner"); + await result.lease.release(); +}); + +test("uncertain worker liveness fails closed during stale-owner recovery", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const ref = workspaceLockRef(key); + const now = Date.now(); + const oldOid = await installOwner(repo, ref, { + version: 1, + token: "uncertain-worker", + hostIdentity: localHostIdentity(), + ownerPid: 34567, + workerState: "starting", + workerPid: null, + acquiredAt: now - 60_000, + heartbeatAt: now - 60_000, + }); + + const result = await tryAcquireGitWorkspaceLock({ + cwd: repo, + key, + now, + staleMs: 1_000, + processProbe: () => "dead", + }); + assert.deepEqual(result, { acquired: false, reason: "held" }); + assert.equal(await git(repo, ["rev-parse", ref]), oldOid); +}); + +test("a stale running lock fails closed even when its original process group is gone", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const ref = workspaceLockRef(key); + const now = Date.now(); + const oldOid = await installOwner(repo, ref, { + version: 1, + token: "escaped-descendant-uncertain", + hostIdentity: localHostIdentity(), + ownerPid: 45678, + workerState: "running", + workerPid: 56789, + acquiredAt: now - 60_000, + heartbeatAt: now - 60_000, + }); + + const result = await tryAcquireGitWorkspaceLock({ + cwd: repo, + key, + now, + staleMs: 1_000, + processProbe: () => "dead", + processGroupProbe: async () => "dead", + }); + assert.deepEqual(result, { acquired: false, reason: "held" }); + assert.equal(await git(repo, ["rev-parse", ref]), oldOid); +}); + +test("an update-ref infrastructure failure is not misclassified as contention", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const ref = workspaceLockRef(key); + const gitDirectory = path.resolve(repo, await git(repo, ["rev-parse", "--git-dir"])); + const refPath = path.join(gitDirectory, ...ref.split("/")); + await mkdir(path.dirname(refPath), { recursive: true }); + const blocker = refPath + ".lock"; + await writeFile(blocker, "intentional test lock\n"); + context.after(() => rm(blocker, { force: true })); + + await assert.rejects( + tryAcquireGitWorkspaceLock({ cwd: repo, key }), + /cannot update workspace lock ref/iu, + ); +}); + +test("a failed release can be recovered by the next holder in every completed state", async (context) => { + for (const state of ["idle", "starting", "running"]) { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const first = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(first.acquired, true); + if (state !== "idle") await first.lease.markWorkerStarting(); + if (state === "running") await first.lease.markWorkerRunning(process.pid); + const gitDirectory = path.resolve(repo, await git(repo, ["rev-parse", "--git-dir"])); + const refPath = path.join(gitDirectory, ...first.lease.ref.split("/")); + await mkdir(path.dirname(refPath), { recursive: true }); + const blocker = refPath + ".lock"; + await writeFile(blocker, "intentional release failure\n"); + await assert.rejects(first.lease.release(), /cannot delete workspace lock ref/iu); + await rm(blocker, { force: true }); + + const second = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(second.acquired, true, "the next local holder should replace the " + state + " ref"); + await second.lease.release(); + } +}); + +test("a recovery-authorization failure cannot prevent exact owner deletion", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const first = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(first.acquired, true); + const ownerOid = await git(repo, ["rev-parse", first.lease.ref]); + const gitDirectory = path.resolve(repo, await git(repo, ["rev-parse", "--git-dir"])); + const recoveryRefPath = path.join( + gitDirectory, ...workspaceRecoveryRef(first.lease.ref, ownerOid).split("/"), + ); + await mkdir(path.dirname(recoveryRefPath), { recursive: true }); + const blocker = recoveryRefPath + ".lock"; + await writeFile(blocker, "intentional recovery authorization failure\n"); + context.after(() => rm(blocker, { force: true })); + + await first.lease.release(); + await assert.rejects( + execFileAsync("git", ["rev-parse", "--verify", first.lease.ref], { cwd: repo }), + /Command failed/u, + ); + await rm(blocker, { force: true }); + const second = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(second.acquired, true, "the live server must not remain blocked by its deleted owner"); + await second.lease.release(); +}); + +test("local recovery records remain isolated across stale owner retries", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const first = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(first.acquired, true); + const ownerOid = await git(repo, ["rev-parse", first.lease.ref]); + const gitDirectory = path.resolve(repo, await git(repo, ["rev-parse", "--git-dir"])); + const ownerRefPath = path.join(gitDirectory, ...first.lease.ref.split("/")); + const recoveryRefPath = path.join( + gitDirectory, ...workspaceRecoveryRef(first.lease.ref, ownerOid).split("/"), + ); + await mkdir(path.dirname(ownerRefPath), { recursive: true }); + await mkdir(path.dirname(recoveryRefPath), { recursive: true }); + const ownerBlocker = ownerRefPath + ".lock"; + const recoveryBlocker = recoveryRefPath + ".lock"; + await writeFile(ownerBlocker, "block exact delete\n"); + await writeFile(recoveryBlocker, "block recovery publication\n"); + context.after(() => rm(ownerBlocker, { force: true })); + context.after(() => rm(recoveryBlocker, { force: true })); + + await assert.rejects( + first.lease.release(), + /cannot persist recovery authorization or delete the workspace lock ref/iu, + ); + await rm(ownerBlocker, { force: true }); + await rm(recoveryBlocker, { force: true }); + + const otherModule = await import("../workspace-lock.mjs?local-abandonment=" + Date.now()); + const outside = await otherModule.tryAcquireGitWorkspaceLock({ + cwd: repo, key, heartbeatMs: 60_000, + }); + assert.deepEqual(outside, { acquired: false, reason: "held" }, + "a new module/process must remain fail-closed without durable authorization"); + + const recovered = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(recovered.acquired, true, + "the same FIFO domain may retry the exact abandoned OID and token"); + const recoveredOid = await git(repo, ["rev-parse", recovered.lease.ref]); + const recoveredRecoveryRefPath = path.join( + gitDirectory, ...workspaceRecoveryRef(recovered.lease.ref, recoveredOid).split("/"), + ); + await mkdir(path.dirname(recoveredRecoveryRefPath), { recursive: true }); + const recoveredRecoveryBlocker = recoveredRecoveryRefPath + ".lock"; + context.after(() => rm(recoveredRecoveryBlocker, { force: true })); + await writeFile(ownerBlocker, "block replacement exact delete\n"); + await writeFile(recoveredRecoveryBlocker, "block replacement recovery publication\n"); + await assert.rejects( + recovered.lease.release(), + /cannot persist recovery authorization or delete the workspace lock ref/iu, + ); + + await rm(ownerBlocker, { force: true }); + await rm(recoveredRecoveryBlocker, { force: true }); + // Make the store briefly unavailable so the stale first lease cannot read + // back the replacement OID after either Git operation fails. Its local + // record must not overwrite the replacement owner's separately keyed entry. + const unavailableRepo = repo + "-unavailable"; + await rename(repo, unavailableRepo); + await assert.rejects( + first.lease.release(), + /cannot persist recovery authorization or delete the workspace lock ref/iu, + ); + await rename(unavailableRepo, repo); + + const stillOutside = await otherModule.tryAcquireGitWorkspaceLock({ + cwd: repo, key, heartbeatMs: 60_000, + }); + assert.deepEqual(stillOutside, { acquired: false, reason: "held" }); + const third = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(third.acquired, true, + "the current exact local record must survive a stale owner's later failure"); + await third.lease.release(); +}); + +test("owner-sharded recovery survives a stale prior holder retry", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const first = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(first.acquired, true); + const firstOid = await git(repo, ["rev-parse", first.lease.ref]); + const firstRecoveryRef = workspaceRecoveryRef(first.lease.ref, firstOid); + const gitDirectory = path.resolve(repo, await git(repo, ["rev-parse", "--git-dir"])); + const ownerRefPath = path.join(gitDirectory, ...first.lease.ref.split("/")); + await mkdir(path.dirname(ownerRefPath), { recursive: true }); + const blocker = ownerRefPath + ".lock"; + await writeFile(blocker, "block first release\n"); + await assert.rejects(first.lease.release(), /cannot delete workspace lock ref/iu); + assert.match(await git(repo, ["rev-parse", firstRecoveryRef]), /^[0-9a-f]{40,64}$/u); + + await rm(blocker, { force: true }); + const second = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(second.acquired, true); + const secondOid = await git(repo, ["rev-parse", second.lease.ref]); + const secondRecoveryRef = workspaceRecoveryRef(second.lease.ref, secondOid); + await writeFile(blocker, "block second release\n"); + await assert.rejects(second.lease.release(), /cannot delete workspace lock ref/iu); + const secondAuthorizationOid = await git(repo, ["rev-parse", secondRecoveryRef]); + + await rm(blocker, { force: true }); + await assert.rejects(first.lease.release(), /ownership changed before release/iu); + assert.equal(await git(repo, ["rev-parse", secondRecoveryRef]), secondAuthorizationOid, + "a stale holder must not overwrite or clear the current owner's authorization"); + await assert.rejects( + execFileAsync("git", ["rev-parse", "--verify", firstRecoveryRef], { cwd: repo }), + /Command failed/u, + ); + + const third = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(third.acquired, true, "the current owner's shard must authorize exact takeover"); + await third.lease.release(); +}); + +test("a legacy recovery authorization is consumed only when it authorizes the current owner", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const ref = workspaceLockRef(key); + const token = "legacy-recovery-owner"; + const now = Date.now(); + const ownerOid = await installOwner(repo, ref, { + version: 1, + token, + hostIdentity: localHostIdentity(), + ownerPid: process.pid, + ownerIdentity: null, + workerState: "idle", + workerPid: null, + acquiredAt: now, + heartbeatAt: now, + }); + const legacyRef = ref + ".recovery"; + await installOwner(repo, legacyRef, { + version: 1, lockRef: ref, ownerOid, ownerToken: token, authorizedAt: now, + }); + + const acquired = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(acquired.acquired, true); + await assert.rejects( + execFileAsync("git", ["rev-parse", "--verify", legacyRef], { cwd: repo }), + /Command failed/u, + ); + await acquired.lease.release(); +}); + +test("an owner shard takes precedence without clearing a legacy authorization", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const ref = workspaceLockRef(key); + const token = "shard-precedence-owner"; + const now = Date.now(); + const ownerOid = await installOwner(repo, ref, { + version: 1, + token, + hostIdentity: localHostIdentity(), + ownerPid: process.pid, + ownerIdentity: null, + workerState: "idle", + workerPid: null, + acquiredAt: now, + heartbeatAt: now, + }); + const authorization = { version: 1, lockRef: ref, ownerOid, ownerToken: token }; + const shardRef = workspaceRecoveryRef(ref, ownerOid); + await installOwner(repo, shardRef, authorization); + const legacyRef = ref + ".recovery"; + const legacyOid = await installOwner(repo, legacyRef, { + ...authorization, legacyFixture: true, + }); + + const acquired = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(acquired.acquired, true); + await assert.rejects( + execFileAsync("git", ["rev-parse", "--verify", shardRef], { cwd: repo }), + /Command failed/u, + ); + assert.equal(await git(repo, ["rev-parse", legacyRef]), legacyOid, + "the successful CAS must clear only the authorization it actually used"); + await git(repo, ["update-ref", "-d", legacyRef, legacyOid]); + await acquired.lease.release(); +}); + +test("a failed release in one linked worktree is recoverable from another", async (context) => { + const repo = await makeRepo(context); + await git(repo, ["config", "user.email", "fixture@example.com"]); + await git(repo, ["config", "user.name", "Fixture"]); + await git(repo, ["commit", "--allow-empty", "-m", "baseline"]); + const linked = path.join(path.dirname(repo), "linked"); + await git(repo, ["worktree", "add", "-b", "linked", linked]); + const key = "git-common-dir:shared-fixture"; + const first = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(first.acquired, true); + const commonDirectory = path.resolve(repo, await git(repo, ["rev-parse", "--git-common-dir"])); + const refPath = path.join(commonDirectory, ...first.lease.ref.split("/")); + await mkdir(path.dirname(refPath), { recursive: true }); + const blocker = refPath + ".lock"; + await writeFile(blocker, "intentional linked-worktree release failure\n"); + await assert.rejects(first.lease.release(), /cannot delete workspace lock ref/iu); + await rm(blocker, { force: true }); + + // A cache-busted module instance has no shared JavaScript memory with the + // first holder and therefore proves recovery authorization lives in Git. + const otherModule = await import("../workspace-lock.mjs?shared-recovery=" + Date.now()); + const second = await otherModule.tryAcquireGitWorkspaceLock({ + cwd: linked, key, heartbeatMs: 60_000, + }); + assert.equal(second.acquired, true, + "the repository-scoped recovery record is visible across module/process boundaries"); + await second.lease.release(); +}); + +test("periodic ownership probes do not create heartbeat blobs", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const result = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 10 }); + assert.equal(result.acquired, true); + await new Promise((resolve) => setTimeout(resolve, 120)); + const countOutput = await git(repo, ["count-objects", "-v"]); + const looseCount = Number(/^count:\s+(\d+)$/mu.exec(countOutput)?.[1]); + assert.equal(looseCount, 2, + "read-only heartbeat probes must add nothing beyond the owner and one activity blob"); + await result.lease.release(); +}); + +test("each acquisition publishes a unique clock-independent activity marker", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const historyRef = workspaceHistoryRef(key); + const first = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(first.acquired, true); + const firstOid = (await git(repo, ["rev-parse", historyRef])).trim(); + const firstRecord = JSON.parse(await git(repo, ["cat-file", "blob", firstOid])); + await first.lease.release(); + + const second = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(second.acquired, true); + const secondOid = (await git(repo, ["rev-parse", historyRef])).trim(); + const secondRecord = JSON.parse(await git(repo, ["cat-file", "blob", secondOid])); + assert.notEqual(secondOid, firstOid, "activity identity must not depend on Date.now granularity"); + assert.notEqual(secondRecord.ownerToken, firstRecord.ownerToken); + assert.match(secondRecord.ownerOid, /^[0-9a-f]{40,64}$/u); + await second.lease.release(); +}); + +test("post-CAS cancellation reconciles the committed owner before returning", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const ref = workspaceLockRef(key); + const gitDirectory = path.resolve(repo, await git(repo, ["rev-parse", "--git-dir"])); + const refPath = path.join(gitDirectory, ...ref.split("/")); + await mkdir(path.dirname(refPath), { recursive: true }); + const blocker = refPath + ".lock"; + const startedFile = path.join(path.dirname(repo), "cas-result-started"); + const releaseFile = path.join(path.dirname(repo), "cas-result-release"); + const saved = { + nodeEnv: process.env.NODE_ENV, + started: process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE, + release: process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE, + }; + process.env.NODE_ENV = "test"; + process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE = startedFile; + process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE = releaseFile; + context.after(() => { + if (saved.nodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = saved.nodeEnv; + if (saved.started === undefined) { + delete process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE; + } else process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE = saved.started; + if (saved.release === undefined) { + delete process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE; + } else process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE = saved.release; + }); + const cancel = cancellationToken(); + const acquisition = tryAcquireGitWorkspaceLock({ + cwd: repo, key, cancel, heartbeatMs: 60_000, + }); + await waitForFile(startedFile); + assert.match(await git(repo, ["rev-parse", ref]), /^[0-9a-f]{40,64}$/u, + "the real acquisition CAS must commit before cancellation"); + await writeFile(blocker, "intentional compensating-delete failure\n"); + cancel.cancel(); + await writeFile(releaseFile, "release\n"); + await assert.rejects(acquisition, WorkspaceLockCancelledError); + assert.match(await git(repo, ["rev-parse", ref]), /^[0-9a-f]{40,64}$/u, + "a failed exact delete must leave a recoverable owner ref"); + await rm(blocker, { force: true }); + delete process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE; + delete process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE; + const recovered = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(recovered.acquired, true); + await recovered.lease.release(); +}); + +test("post-CAS deadline reconciliation deletes the exact committed owner", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const ref = workspaceLockRef(key); + const startedFile = path.join(path.dirname(repo), "cas-deadline-started"); + const releaseFile = path.join(path.dirname(repo), "cas-deadline-release"); + const saved = { + nodeEnv: process.env.NODE_ENV, + started: process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE, + release: process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE, + }; + process.env.NODE_ENV = "test"; + process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE = startedFile; + process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE = releaseFile; + context.after(() => { + if (saved.nodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = saved.nodeEnv; + if (saved.started === undefined) { + delete process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE; + } else process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE = saved.started; + if (saved.release === undefined) { + delete process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE; + } else process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE = saved.release; + }); + const passive = { cancelled: false, promise: new Promise(() => {}), subscribe: () => () => {} }; + const deadline = Date.now() + 3_000; + const acquisition = tryAcquireGitWorkspaceLock({ + cwd: repo, key, cancel: passive, deadline, heartbeatMs: 60_000, + }); + await waitForFile(startedFile); + assert.match(await git(repo, ["rev-parse", ref]), /^[0-9a-f]{40,64}$/u); + await new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now() + 20))); + await writeFile(releaseFile, "release\n"); + await assert.rejects(acquisition, WorkspaceLockDeadlineError); + await assert.rejects( + execFileAsync("git", ["rev-parse", "--verify", ref], { cwd: repo }), /Command failed/u, + ); + delete process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE; + delete process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE; + const recovered = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(recovered.acquired, true); + await recovered.lease.release(); +}); + +test("interrupted state CAS cleanup covers both exact commit outcomes", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const ref = workspaceLockRef(key); + const result = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); + assert.equal(result.acquired, true); + const previousOid = await git(repo, ["rev-parse", ref]); + const startedFile = path.join(path.dirname(repo), "state-cas-result-started"); + const releaseFile = path.join(path.dirname(repo), "state-cas-result-release"); + const saved = { + nodeEnv: process.env.NODE_ENV, + started: process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE, + release: process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE, + readFailures: process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_RELEASE_READBACK_FAILURES, + }; + process.env.NODE_ENV = "test"; + process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE = startedFile; + process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE = releaseFile; + context.after(() => { + if (saved.nodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = saved.nodeEnv; + if (saved.started === undefined) delete process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE; + else process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE = saved.started; + if (saved.release === undefined) delete process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE; + else process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE = saved.release; + if (saved.readFailures === undefined) { + delete process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_RELEASE_READBACK_FAILURES; + } else { + process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_RELEASE_READBACK_FAILURES = saved.readFailures; + } + }); + const cancel = cancellationToken(); + const update = result.lease.markWorkerStarting({ cancel }); + await waitForFile(startedFile); + const candidateOid = await git(repo, ["rev-parse", ref]); + assert.notEqual(candidateOid, previousOid, "the real state CAS must commit its candidate OID"); + cancel.cancel(); + await writeFile(releaseFile, "release\n"); + await assert.rejects(update, WorkspaceLockCancelledError); + delete process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE; + delete process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE; + process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_RELEASE_READBACK_FAILURES = "1"; + + await result.lease.release(); + await assert.rejects( + execFileAsync("git", ["rev-parse", "--verify", ref], { cwd: repo }), /Command failed/u, + ); + const otherModule = await import("../workspace-lock.mjs?state-cas-recovery=" + Date.now()); + const recovered = await otherModule.tryAcquireGitWorkspaceLock({ + cwd: repo, key, heartbeatMs: 60_000, + }); + assert.equal(recovered.acquired, true); + await recovered.lease.release(); +}); + +test("long lock waits unsubscribe cancellation listeners after every retry", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const holder = await tryAcquireGitWorkspaceLock({ + cwd: repo, + key, + ownerPid: 111_111, + heartbeatMs: 60_000, + }); + assert.equal(holder.acquired, true); + const listeners = new Set(); + let resolveCancelled; + let maximumListeners = 0; + const cancel = { + cancelled: false, + promise: new Promise((resolve) => { resolveCancelled = resolve; }), + subscribe(listener) { + listeners.add(listener); + maximumListeners = Math.max(maximumListeners, listeners.size); + return () => { listeners.delete(listener); }; + }, + cancel() { + this.cancelled = true; + resolveCancelled(); + for (const listener of [...listeners]) listener(); + }, + }; + + const waiting = acquireGitWorkspaceLock({ + cwd: repo, + key, + ownerPid: 222_222, + cancel, + pollMs: 5, + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + cancel.cancel(); + await assert.rejects(waiting, /cancelled/iu); + assert.equal(listeners.size, 0); + assert.ok(maximumListeners <= 1, "listeners accumulated across retries: " + String(maximumListeners)); + await holder.lease.release(); +}); + +test("worker state updates honour the delegation cancellation and deadline", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const acquire = () => tryAcquireGitWorkspaceLock({ + cwd: repo, key, processProbe: () => "alive", heartbeatMs: 60_000, + }); + + // One interruption ends a lease's update lifecycle, so each case uses its own. + const cancelledLease = await acquire(); + assert.equal(cancelledLease.acquired, true); + const cancelled = { cancelled: true, promise: Promise.resolve(), subscribe: () => () => {} }; + await assert.rejects(cancelledLease.lease.markWorkerStarting({ cancel: cancelled }), WorkspaceLockCancelledError); + await cancelledLease.lease.release(); + await assert.rejects(execFileAsync("git", ["rev-parse", "--verify", workspaceLockRef(key)], { cwd: repo }), /Command failed/u); + + const expiredLease = await acquire(); + assert.equal(expiredLease.acquired, true); + const expired = { cancelled: false, promise: new Promise(() => {}), subscribe: () => () => {} }; + await assert.rejects( + expiredLease.lease.markWorkerStarting({ cancel: expired, deadline: Date.now() - 1 }), + WorkspaceLockDeadlineError, + ); + await expiredLease.lease.release(); + await assert.rejects(execFileAsync("git", ["rev-parse", "--verify", workspaceLockRef(key)], { cwd: repo }), /Command failed/u); +}); + +test("workspace-lock Git operations disable repository transaction hooks", { + // This fixture depends on executable-hook semantics. + skip: process.platform !== "linux", +}, async (context) => { + const repo = await makeRepo(context); + const gitDirectory = path.resolve(repo, await git(repo, ["rev-parse", "--git-dir"])); + const hook = path.join(gitDirectory, "hooks", "reference-transaction"); + const ready = path.join(path.dirname(repo), "hook-ready"); + const release = path.join(path.dirname(repo), "hook-release"); + await writeFile(hook, [ + "#!/usr/bin/env node", + "const { existsSync, writeFileSync } = require('node:fs');", + `writeFileSync(${JSON.stringify(ready)}, 'ready\\n');`, + `while (!existsSync(${JSON.stringify(release)})) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);`, + "", + ].join("\n")); + await chmod(hook, 0o755); + context.after(() => writeFile(release, "release\n").catch(() => {})); + const acquisition = await tryAcquireGitWorkspaceLock({ + cwd: repo, key: "git-worktree:" + repo, heartbeatMs: 60_000, + }); + assert.equal(acquisition.acquired, true); + await assert.rejects(access(ready), /ENOENT/u, + "coordination refs must never execute repository-controlled transaction hooks"); + await acquisition.lease.release(); +}); + +test("workspace-lock Git resolution detaches cancelled and expired waiters", async (context) => { + const repo = await makeRepo(context); + const startedFile = path.join(path.dirname(repo), "git-resolution-started.txt"); + const moduleUrl = new URL("../workspace-lock.mjs", import.meta.url).href; + const source = [ + `import {tryAcquireGitWorkspaceLock,WorkspaceLockCancelledError,WorkspaceLockDeadlineError} from ${JSON.stringify(moduleUrl)}`, + "const listeners=new Set()", + "let resolveCancelled", + "const cancel={cancelled:false,promise:new Promise(r=>{resolveCancelled=r}),subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn)},cancel(){this.cancelled=true;resolveCancelled();for(const fn of [...listeners])fn()}}", + "setTimeout(()=>cancel.cancel(),100)", + `const repo=${JSON.stringify(repo)}`, + "const outcomes=[]", + "try{await tryAcquireGitWorkspaceLock({cwd:repo,key:'cancelled-resolution',cancel,deadline:Date.now()+5000})}catch(error){outcomes.push(error instanceof WorkspaceLockCancelledError?'cancelled':error.name)}", + "const passive={cancelled:false,promise:new Promise(()=>{}),subscribe(){return()=>{}}}", + "try{await tryAcquireGitWorkspaceLock({cwd:repo,key:'expired-resolution',cancel:passive,deadline:Date.now()+200})}catch(error){outcomes.push(error instanceof WorkspaceLockDeadlineError?'deadline':error.name)}", + "process.stdout.write(outcomes.join(','))", + "process.exit(outcomes.join(',')==='cancelled,deadline'?0:3)", + ].join(";"); + const { stdout } = await execFileAsync(process.execPath, [ + "--input-type=module", "-e", source, + ], { + timeout: 5_000, + env: { + ...process.env, + NODE_ENV: "test", + CLI_AGENT_BRIDGE_TEST_GIT_RESOLUTION_DELAY_MS: "60000", + CLI_AGENT_BRIDGE_TEST_GIT_RESOLUTION_STARTED_FILE: startedFile, + }, + }); + assert.equal(stdout, "cancelled,deadline"); + assert.equal((await readFile(startedFile, "utf8")).trim().split(/\r?\n/u).length, 1, + "both abandoned callers must share one underlying Git lookup"); + + const gitModuleUrl = new URL("../git-executable.mjs", import.meta.url).href; + const spawnMarker = path.join(path.dirname(repo), "workspace-git-spawned.txt"); + const launchGuardSource = [ + `import {tryAcquireGitWorkspaceLock,WorkspaceLockDeadlineError} from ${JSON.stringify(moduleUrl)}`, + `import {trustedGitExecutable} from ${JSON.stringify(gitModuleUrl)}`, + "await trustedGitExecutable()", + "process.env.NODE_ENV='test'", + "process.env.CLI_AGENT_BRIDGE_TEST_GIT_INVOCATION_DELAY_MS='250'", + `process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_GIT_SPAWNED_FILE=${JSON.stringify(spawnMarker)}`, + `const repo=${JSON.stringify(repo)}`, + "const passive={cancelled:false,promise:new Promise(()=>{}),subscribe(){return()=>{}}}", + "let outcome='resolved'", + "try{await tryAcquireGitWorkspaceLock({cwd:repo,key:'post-builder-deadline',cancel:passive,deadline:Date.now()+100})}catch(error){outcome=error instanceof WorkspaceLockDeadlineError?'deadline':error.name}", + "process.stdout.write(outcome)", + "process.exit(outcome==='deadline'?0:3)", + ].join(";"); + const launchGuard = await execFileAsync(process.execPath, [ + "--input-type=module", "-e", launchGuardSource, + ], { timeout: 5_000, env: { ...process.env } }); + assert.equal(launchGuard.stdout, "deadline", + "workspace-lock must recheck the absolute deadline immediately before Git launch"); + await assert.rejects(access(spawnMarker), /ENOENT/u, + "workspace-lock must not spawn Git after invocation setup exhausts the deadline"); +}); + +test("quarantined leases require an explicit token-bound recovery approval", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const ref = workspaceLockRef(key); + const now = Date.now(); + await installOwner(repo, ref, { + version: 1, + token: "quarantined-owner", + hostIdentity: localHostIdentity(), + ownerPid: process.pid, + workerState: "quarantined", + quarantineMarkerPersisted: true, + quarantineId: "quarantine-incident-1", + workerPid: 4242, + acquiredAt: now, + heartbeatAt: now, + }); + + const stillHeld = await tryAcquireGitWorkspaceLock({ + cwd: repo, key, now, staleMs: 60_000, processProbe: () => "alive", + operatorRecoveryApproved: () => false, + }); + assert.deepEqual(stillHeld, { acquired: false, reason: "held" }); + + const reclaimed = await tryAcquireGitWorkspaceLock({ + cwd: repo, key, now, staleMs: 60_000, processProbe: () => "alive", + operatorRecoveryApproved: (owner) => owner.quarantineId === "quarantine-incident-1", + }); + assert.equal(reclaimed.acquired, true, "a matching durable approval authorizes takeover"); + await reclaimed.lease.release(); +}); + +test("a quarantined lease without proof of a durable marker fails closed", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const now = Date.now(); + await installOwner(repo, workspaceLockRef(key), { + version: 1, + token: "marker-never-persisted", + hostIdentity: localHostIdentity(), + ownerPid: 12345, + workerState: "quarantined", + workerPid: 5353, + acquiredAt: now - 120_000, + heartbeatAt: now - 120_000, + }); + const result = await tryAcquireGitWorkspaceLock({ + cwd: repo, key, now, staleMs: 30_000, processProbe: () => "dead", + operatorRecoveryApproved: () => true, + }); + assert.deepEqual(result, { acquired: false, reason: "held" }, + "approval is invalid unless marker persistence and an incident id were recorded"); +}); + +test("a quarantine publication interrupted before its marker remains fail closed", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const now = Date.now(); + await installOwner(repo, workspaceLockRef(key), { + version: 1, + token: "quarantine-publication-pending", + hostIdentity: localHostIdentity(), + ownerPid: 12345, + workerState: "quarantine-pending", + quarantineMarkerPersisted: false, + quarantineId: "pending-incident", + workerPid: null, + acquiredAt: now - 120_000, + heartbeatAt: now - 120_000, + }); + const result = await tryAcquireGitWorkspaceLock({ + cwd: repo, key, now, staleMs: 30_000, processProbe: () => "dead", + operatorRecoveryApproved: () => true, + }); + assert.deepEqual(result, { acquired: false, reason: "held" }); +}); + +test("a different OS user cannot clear another user's quarantined lease", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const now = Date.now(); + await installOwner(repo, workspaceLockRef(key), { + version: 1, + token: "other-user-quarantine", + hostIdentity: localHostIdentity() + ":other-user", + ownerPid: 12345, + ownerIdentity: "other-user-process", + workerState: "quarantined", + quarantineMarkerPersisted: true, + quarantineId: "other-user-incident", + workerPid: 5353, + acquiredAt: now - 120_000, + heartbeatAt: now - 120_000, + }); + const result = await tryAcquireGitWorkspaceLock({ + cwd: repo, key, now, staleMs: 30_000, processProbe: () => "dead", + operatorRecoveryApproved: () => true, + }); + assert.deepEqual(result, { acquired: false, reason: "held" }); +}); + +test("a crashed quarantined owner remains held without explicit recovery approval", async (context) => { + const repo = await makeRepo(context); + const key = "git-worktree:" + repo; + const now = Date.now(); + await installOwner(repo, workspaceLockRef(key), { + version: 1, + token: "crashed-quarantine", + hostIdentity: localHostIdentity(), + ownerPid: 12345, + workerState: "quarantined", + quarantineMarkerPersisted: true, + quarantineId: "crashed-incident", + workerPid: 5353, + acquiredAt: now - 120_000, + heartbeatAt: now - 120_000, + }); + const result = await tryAcquireGitWorkspaceLock({ + cwd: repo, key, now, staleMs: 30_000, processProbe: () => "dead", + operatorRecoveryApproved: () => false, + }); + assert.deepEqual(result, { acquired: false, reason: "held" }, + "owner death cannot prove that escaped descendants terminated"); +}); diff --git a/plugins/Hylouis233/cli-agent-bridge/windows-job-runner.ps1 b/plugins/Hylouis233/cli-agent-bridge/windows-job-runner.ps1 new file mode 100644 index 0000000..74d5ad8 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/windows-job-runner.ps1 @@ -0,0 +1,160 @@ +param( + [Parameter(Mandatory = $true)] + [string]$NodeExecutable, + [Parameter(Mandatory = $true)] + [string]$NodeRunner +) + +$ErrorActionPreference = "Stop" + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class CliAgentBridgeJobObject +{ + private const int JobObjectExtendedLimitInformation = 9; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateJobObject(IntPtr jobAttributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetInformationJobObject( + IntPtr job, int informationClass, IntPtr information, uint informationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + + [DllImport("kernel32.dll")] + private static extern IntPtr GetCurrentProcess(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); + + private static Win32Exception LastError(string operation) + { + return new Win32Exception(Marshal.GetLastWin32Error(), operation + " failed"); + } + + public static IntPtr CreateKillOnCloseJobForCurrentProcess() + { + IntPtr job = CreateJobObject(IntPtr.Zero, null); + if (job == IntPtr.Zero) throw LastError("CreateJobObject"); + try + { + var limits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + int size = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + IntPtr buffer = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(limits, buffer, false); + if (!SetInformationJobObject( + job, JobObjectExtendedLimitInformation, buffer, (uint)size)) + throw LastError("SetInformationJobObject"); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + if (!AssignProcessToJobObject(job, GetCurrentProcess())) + throw LastError("AssignProcessToJobObject"); + return job; + } + catch + { + CloseHandle(job); + throw; + } + } +} +'@ + +$jobHandle = [IntPtr]::Zero +try { + # Containment must exist before stdin is read. The backend therefore cannot + # start when Job creation, configuration, or nested assignment fails. + $jobHandle = [CliAgentBridgeJobObject]::CreateKillOnCloseJobForCurrentProcess() +} +catch { + [Console]::Error.WriteLine( + "job-object initialization failed: " + $_.Exception.GetBaseException().Message) + exit 125 +} + +try { + # Node's spawn argument array preserves arbitrary task text exactly. Keep + # the PowerShell layer responsible only for the Windows Job lifetime and + # pipe the opaque JSON payload to the cross-platform runner. + # Node writes JSON as UTF-8. Windows PowerShell 5.1 otherwise decodes stdin + # with the console code page and encodes native-pipeline input as ASCII. + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [Console]::InputEncoding = $utf8NoBom + [Console]::OutputEncoding = $utf8NoBom + $OutputEncoding = $utf8NoBom + $payloadText = [Console]::In.ReadToEnd() + $global:LASTEXITCODE = $null + $payloadText | & $NodeExecutable $NodeRunner + $succeeded = $? + $nativeExitCode = $LASTEXITCODE + if ($null -ne $nativeExitCode) { + $runnerExitCode = [int]$nativeExitCode + } + elseif ($succeeded) { + $runnerExitCode = 0 + } + else { + $runnerExitCode = 1 + } +} +catch { + [Console]::Error.WriteLine($_.Exception.GetBaseException().Message) + $runnerExitCode = 127 +} + +# The runner belongs to this Job. Keeping the last handle alive until process +# teardown makes Windows terminate every surviving descendant on normal exit, +# cancellation, timeout, or an abrupt parent-side kill. +[GC]::KeepAlive($jobHandle) +exit $runnerExitCode diff --git a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs new file mode 100644 index 0000000..910b910 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs @@ -0,0 +1,857 @@ +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { appendFile } from "node:fs/promises"; +import os from "node:os"; + +import { safeGitInvocation, subscribeTrustedGitExecutable } from "./git-executable.mjs"; + +export const WORKSPACE_LOCK_REF_PREFIX = "refs/cli-agent-bridge/workspace-locks/"; +const WORKSPACE_HISTORY_REF_SUFFIX = ".history"; +const LEGACY_WORKSPACE_RECOVERY_REF_SUFFIX = ".recovery"; +const WORKSPACE_RECOVERY_REF_PREFIX = "refs/cli-agent-bridge/workspace-recoveries/"; +const DEFAULT_STALE_MS = 30_000; +const DEFAULT_HEARTBEAT_MS = 5_000; +const DEFAULT_POLL_MS = 100; +const GIT_TIMEOUT_MS = 5_000; +const CAPTURE_LIMIT = 64_000; +const PIPE_DRAIN_MS = 100; +const RELEASE_RETRY_MS = 50; +const RELEASE_ATTEMPTS = 3; +const locallyAbandonedRefs = new Map(); +export class WorkspaceLockCancelledError extends Error {} +export class WorkspaceLockDeadlineError extends Error {} + +export function localHostIdentity() { + let userIdentity; + try { + const user = os.userInfo(); + userIdentity = Number.isInteger(user.uid) && user.uid >= 0 + ? "uid:" + String(user.uid) + : "user:" + user.username + ":" + user.homedir; + } catch { + userIdentity = "user:" + (process.env.USERNAME ?? process.env.USER ?? "unknown"); + } + return `${process.platform}:${os.hostname().toLowerCase()}:${userIdentity}`; +} + +export function workspaceLockRef(key) { + return WORKSPACE_LOCK_REF_PREFIX + createHash("sha256").update(key).digest("hex"); +} + +// Every acquired lease publishes a unique persistent activity record before it +// can return to a caller. Snapshots compare this ref's object id rather than +// wall-clock timestamps, which are not comparable across repository hosts. +export function workspaceHistoryRef(key) { + return workspaceLockRef(key) + WORKSPACE_HISTORY_REF_SUFFIX; +} + +async function writeRunHistory(cwd, lockRef, ownerOid, owner) { + const deadline = Date.now() + GIT_TIMEOUT_MS; + const record = { + version: 2, + ownerOid, + ownerToken: owner.token, + hostIdentity: owner.hostIdentity, + }; + const oidResult = await runGit(cwd, ["hash-object", "-w", "--stdin"], { + stdinText: JSON.stringify(record) + "\n", + deadline, + }); + const oid = oidResult.stdout.trim(); + if (oidResult.exitCode !== 0 || !/^[0-9a-f]{40,64}$/u.test(oid)) { + throw new Error("cannot write workspace activity marker blob"); + } + let lastError = null; + for (let attempt = 0; attempt < RELEASE_ATTEMPTS; attempt += 1) { + try { + const result = await runGit(cwd, [ + "update-ref", "--no-deref", lockRef + WORKSPACE_HISTORY_REF_SUFFIX, oid, + ], { deadline }); + if (result.exitCode === 0) return; + lastError = new Error(result.stderr || "cannot publish workspace activity marker"); + } catch (error) { + lastError = error; + } + if (attempt + 1 < RELEASE_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, RELEASE_RETRY_MS)); + } + } + throw new Error("cannot publish workspace activity marker", { cause: lastError }); +} + +export function workspaceRecoveryRef(lockRef, ownerOid) { + const lockDigest = lockRef.startsWith(WORKSPACE_LOCK_REF_PREFIX) + ? lockRef.slice(WORKSPACE_LOCK_REF_PREFIX.length) + : ""; + if (!/^[0-9a-f]{64}$/u.test(lockDigest) || !/^[0-9a-f]{40,64}$/u.test(ownerOid)) { + throw new Error("workspace recovery authorization identity is invalid"); + } + return WORKSPACE_RECOVERY_REF_PREFIX + lockDigest + "/" + ownerOid; +} + +function validRecoveryAuthorization(observed, lockRef, ownerOid, ownerToken) { + return Boolean( + observed?.owner?.version === 1 && + observed.owner.lockRef === lockRef && + observed.owner.ownerOid === ownerOid && + observed.owner.ownerToken === ownerToken, + ); +} + +async function writeRecoveryAuthorization(cwd, lockRef, ownerOid, ownerToken) { + const recoveryRef = workspaceRecoveryRef(lockRef, ownerOid); + const record = { + version: 1, + lockRef, + ownerOid, + ownerToken, + }; + const recordOid = await writeOwnerBlob(cwd, record); + const zeroOid = "0".repeat(recordOid.length); + if (!await compareAndSwap(cwd, recoveryRef, recordOid, zeroOid)) { + const observed = await readCurrentOwner(cwd, recoveryRef); + if (observed?.oid !== recordOid || + !validRecoveryAuthorization(observed, lockRef, ownerOid, ownerToken)) { + throw new Error("workspace lock recovery authorization conflicts with another record"); + } + } + return { ref: recoveryRef, oid: recordOid, owner: record, legacy: false }; +} + +async function readRecoveryAuthorization(cwd, lockRef, ownerOid, ownerToken, options = {}) { + const recoveryRef = workspaceRecoveryRef(lockRef, ownerOid); + const current = await readCurrentOwner(cwd, recoveryRef, options); + if (validRecoveryAuthorization(current, lockRef, ownerOid, ownerToken)) { + return { ...current, ref: recoveryRef, legacy: false }; + } + const legacyRef = lockRef + LEGACY_WORKSPACE_RECOVERY_REF_SUFFIX; + const legacy = await readCurrentOwner(cwd, legacyRef, options); + if (validRecoveryAuthorization(legacy, lockRef, ownerOid, ownerToken)) { + return { ...legacy, ref: legacyRef, legacy: true }; + } + return null; +} + +async function clearRecoveryAuthorization(cwd, authorization) { + if (!authorization) return; + try { + await compareAndDelete(cwd, authorization.ref, authorization.oid); + } catch { + // A stale authorization is harmless because it names one exact owner OID + // and token. A later holder can overwrite or remove it. + } +} + +async function maintainLockStore(cwd) { + try { + // Git's automatic maintenance uses its own repository locks and its normal + // prune grace period, so it is safe alongside acquisitions in other bridge + // processes while eventually collecting superseded owner/history blobs. + await runGit(cwd, ["gc", "--auto", "--quiet"]); + } catch { + // Maintenance is best effort and must never change lock correctness. + } +} + +export function probeProcess(pid) { + if (!Number.isInteger(pid) || pid <= 0) return "unknown"; + if (process.platform === "linux") { + try { + const raw = readFileSync("/proc/" + String(pid) + "/stat", "utf8"); + const close = raw.lastIndexOf(")"); + const state = close < 0 ? "" : raw.slice(close + 1).trim().split(/\s+/u)[0]; + if (["Z", "X", "x"].includes(state)) return "dead"; + } catch (error) { + if (error?.code === "ENOENT") return "dead"; + // Fall through: kill(0) may still positively prove alive/dead. + } + } + try { + process.kill(pid, 0); + return "alive"; + } catch (error) { + if (error?.code === "ESRCH") return "dead"; + if (error?.code === "EPERM") return "alive"; + return "unknown"; + } +} + +function appendBounded(current, chunk) { + const combined = current + chunk; + return combined.length > CAPTURE_LIMIT ? combined.slice(-CAPTURE_LIMIT) : combined; +} + +function checkInterrupted(cancel, deadline) { + if (cancel?.cancelled) throw new WorkspaceLockCancelledError("workspace lock acquisition cancelled"); + if (deadline !== null && Date.now() >= deadline) { + throw new WorkspaceLockDeadlineError("workspace lock acquisition deadline exceeded"); + } +} + +function localAbandonmentKey(cwd, ref, ownerOid, ownerToken) { + return String(cwd) + "\0" + ref + "\0" + ownerOid + "\0" + ownerToken; +} + +function clearExactLocalAbandonment(cwd, ref, ownerOid, ownerToken) { + locallyAbandonedRefs.delete(localAbandonmentKey(cwd, ref, ownerOid, ownerToken)); +} + +function resolveTrustedGitExecutable(cancel, deadline) { + checkInterrupted(cancel, deadline); + return new Promise((resolve, reject) => { + let settled = false; + let timer = null; + let unsubscribeCancel = () => {}; + let unsubscribeResolution = () => {}; + const finish = (callback, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + unsubscribeCancel(); + unsubscribeResolution(); + callback(value); + }; + const cancelled = () => finish( + reject, new WorkspaceLockCancelledError("workspace lock acquisition cancelled"), + ); + if (typeof cancel?.subscribe === "function") unsubscribeCancel = cancel.subscribe(cancelled); + else if (cancel?.promise) void cancel.promise.then(cancelled); + if (deadline !== null) { + timer = setTimeout(() => finish( + reject, new WorkspaceLockDeadlineError("workspace lock acquisition deadline exceeded"), + ), Math.max(0, deadline - Date.now())); + } + unsubscribeResolution = subscribeTrustedGitExecutable( + (value) => finish(resolve, value), + (error) => finish(reject, error), + ); + }); +} + +async function runGit(cwd, args, { + stdinText, + cancel = null, + deadline = null, + returnOnTimeout = false, +} = {}) { + checkInterrupted(cancel, deadline); + const resolutionDeadline = deadline ?? Date.now() + GIT_TIMEOUT_MS; + const executable = await resolveTrustedGitExecutable(cancel, resolutionDeadline); + const git = await safeGitInvocation(args, process.env, executable); + checkInterrupted(cancel, resolutionDeadline); + const remaining = resolutionDeadline - Date.now(); + if (remaining <= 0) { + throw new WorkspaceLockDeadlineError("workspace lock acquisition deadline exceeded"); + } + const timeoutMs = Math.min(GIT_TIMEOUT_MS, remaining); + if (process.env.NODE_ENV === "test") { + const spawnedFile = process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_GIT_SPAWNED_FILE; + if (typeof spawnedFile === "string" && spawnedFile) { + await appendFile(spawnedFile, "spawned\n"); + } + } + const result = await new Promise((resolve) => { + const child = spawn(git.command, git.args, { + cwd, + env: git.env, + windowsHide: true, + stdio: [stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + let timedOut = false; + let exitCode = null; + let exitDrainTimer = null; + let unsubscribe = () => {}; + const finish = (exitCode, error = null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(exitDrainTimer); + unsubscribe(); + resolve({ exitCode, stdout, stderr, timedOut, error }); + }; + const terminate = () => { + if (settled) return; + try { child.kill("SIGKILL"); } catch { /* already gone */ } + }; + const timer = setTimeout(() => { + timedOut = true; + terminate(); + }, timeoutMs); + if (typeof cancel?.subscribe === "function") unsubscribe = cancel.subscribe(terminate); + else if (cancel?.promise) void cancel.promise.then(terminate); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout = appendBounded(stdout, chunk); }); + child.stderr.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); }); + child.on("error", (error) => finish(null, error)); + child.on("exit", (code) => { + exitCode = code; + // A hook or helper can leave inherited pipes open after Git exits. Give + // buffered output a brief drain window, then stop waiting on descendants. + exitDrainTimer = setTimeout(() => { + child.stdout.destroy(); + child.stderr.destroy(); + finish(exitCode); + }, PIPE_DRAIN_MS); + }); + child.on("close", (code) => finish(code ?? exitCode)); + if (child.stdin) child.stdin.end(stdinText); + }); + if (process.env.NODE_ENV === "test" && args[0] === "update-ref" && + args.length === 5 && !args.includes("-d") && + process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE && + process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE) { + await appendFile( + process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE, "started\n", + ); + while (true) { + try { + readFileSync(process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE); + break; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + } + checkInterrupted(cancel, deadline); + if (result.timedOut && !returnOnTimeout) { + throw new Error("git " + args[0] + " timed out while managing the workspace lock"); + } + if (result.error) throw new Error("cannot run git while managing the workspace lock: " + result.error.message); + return result; +} + +async function writeOwnerBlob(cwd, owner, options = {}) { + const result = await runGit(cwd, ["hash-object", "-w", "--stdin"], { + ...options, + stdinText: JSON.stringify(owner) + "\n", + }); + const oid = result.stdout.trim(); + if (result.exitCode !== 0 || !/^[0-9a-f]{40,64}$/u.test(oid)) { + throw new Error("cannot write workspace lock owner blob: " + (result.stderr.trim() || "invalid object id")); + } + return oid; +} + +async function readRefOid(cwd, ref, options = {}) { + const resolved = await runGit(cwd, ["rev-parse", "--verify", "--quiet", ref], options); + if (resolved.exitCode === 1 && !resolved.stdout.trim()) return null; + const oid = resolved.stdout.trim(); + if (resolved.exitCode !== 0 || !/^[0-9a-f]{40,64}$/u.test(oid)) { + throw new Error("cannot read workspace lock ref: " + (resolved.stderr.trim() || "invalid object id")); + } + return oid; +} + +async function readCurrentOwner(cwd, ref, options = {}) { + const oid = await readRefOid(cwd, ref, options); + if (oid === null) return null; + const blob = await runGit(cwd, ["cat-file", "blob", oid], options); + if (blob.exitCode !== 0) { + throw new Error("cannot read workspace lock owner blob: " + blob.stderr.trim()); + } + let owner = null; + try { + owner = JSON.parse(blob.stdout); + } catch { + // A malformed owner is deliberately treated as unclaimable. + } + return { oid, owner }; +} + +async function compareAndSwap(cwd, ref, newOid, expectedOid, options = {}) { + const result = await runGit(cwd, ["update-ref", "--no-deref", ref, newOid, expectedOid], { + ...options, + returnOnTimeout: true, + }); + if (result.exitCode === 0) return true; + const observedOid = await readRefOid(cwd, ref, options); + if (observedOid === newOid) return true; + const expectedAbsent = /^0+$/u.test(expectedOid); + if (expectedAbsent ? observedOid !== null : observedOid !== expectedOid) return false; + throw new Error("cannot update workspace lock ref: " + ( + result.stderr.trim() || "git update-ref exited with code " + String(result.exitCode) + )); +} + +async function compareAndDelete(cwd, ref, expectedOid) { + const result = await runGit(cwd, ["update-ref", "--no-deref", "-d", ref, expectedOid], { + returnOnTimeout: true, + }); + if (result.exitCode === 0) return true; + const observedOid = await readRefOid(cwd, ref); + if (observedOid === null) return true; + if (observedOid !== expectedOid) return false; + throw new Error("cannot delete workspace lock ref: " + ( + result.stderr.trim() || "git update-ref exited with code " + String(result.exitCode) + )); +} + +async function removeOwnedRefWithRecovery(cwd, ref, ownerOid, ownerToken) { + let authorization = null; + let authorizationError = null; + let deleteError = null; + let deleted = false; + let ownershipChanged = false; + for (let attempt = 0; attempt < RELEASE_ATTEMPTS; attempt += 1) { + if (!authorization) { + try { + authorization = await writeRecoveryAuthorization(cwd, ref, ownerOid, ownerToken); + authorizationError = null; + } catch (error) { + authorizationError = error; + } + } + try { + deleted = await compareAndDelete(cwd, ref, ownerOid); + deleteError = null; + ownershipChanged = !deleted; + break; + } catch (error) { + deleteError = error; + } + if (attempt + 1 < RELEASE_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, RELEASE_RETRY_MS)); + } + } + if (deleted || ownershipChanged) { + clearExactLocalAbandonment(cwd, ref, ownerOid, ownerToken); + if (authorization) { + await clearRecoveryAuthorization(cwd, authorization); + } else if (deleted) { + try { + const observedAuthorization = await readRecoveryAuthorization( + cwd, ref, ownerOid, ownerToken, + ); + await clearRecoveryAuthorization(cwd, observedAuthorization); + } catch { /* deletion succeeded; stale exact authorization cleanup is best effort */ } + } + return { deleted, authorization: null }; + } + if (deleteError && authorization) throw deleteError; + if (authorizationError && deleteError) { + // Both durable recovery publication and exact deletion failed. release() + // is called only before worker launch or after its tree was confirmed + // terminated, so the next FIFO holder in this module may safely retry one + // exact expected-OID CAS. Other processes/modules remain fail-closed. + locallyAbandonedRefs.set(localAbandonmentKey(cwd, ref, ownerOid, ownerToken), true); + throw new AggregateError( + [authorizationError, deleteError], + "cannot persist recovery authorization or delete the workspace lock ref", + ); + } + throw authorizationError ?? deleteError ?? new Error("cannot release workspace lock ref"); +} + +async function canReclaim(owner, { + now, + staleMs, + hostIdentity, + processProbe, + processIdentityProbe = null, + operatorRecoveryApproved = null, +}) { + if (!owner || owner.version !== 1 || owner.hostIdentity !== hostIdentity) return false; + if (!Number.isFinite(owner.heartbeatAt)) return false; + // A quarantined lease means termination already failed and the operator was + // told to inspect leftovers. It becomes reclaimable only through a durable, + // token-bound recovery authorization. Owner death cannot prove that an + // escaped descendant died too, and mere marker absence may be routine + // temporary-directory cleanup; neither is approval. + if (owner.workerState === "quarantined") { + // This bit is written only after the shared marker was durably created. + // Older/partial records cannot distinguish "operator removed" from + // "marker creation failed" and therefore remain fail-closed. + if (owner.quarantineMarkerPersisted !== true) return false; + if (typeof owner.quarantineId === "string" && owner.quarantineId && operatorRecoveryApproved) { + try { + if (await operatorRecoveryApproved(owner)) return true; + } catch { /* treat a failed check as not approved */ } + } + return false; + } + // Publication starts by persisting this state before any temporary marker + // work. A bridge crash in that window cannot turn an uncertain escaped tree + // back into a reclaimable idle lease. + if (owner.workerState === "quarantine-pending") return false; + if (now - owner.heartbeatAt < staleMs) return false; + if (await originalOwnerStatus(owner, processProbe, processIdentityProbe) !== "dead") return false; + if (owner.workerState === "idle" && owner.workerPid === null) return true; + // Starting/running records always fail closed. The live bridge tracks + // descendants that escape into new POSIX sessions, but that in-memory tree + // cannot be reconstructed after an owner crash from workerPid alone. + return false; +} + +async function originalOwnerStatus(owner, processProbe, processIdentityProbe) { + const status = await processProbe(owner.ownerPid); + if (status !== "alive" || !owner.ownerIdentity || !processIdentityProbe) return status; + try { + const observed = await processIdentityProbe(owner.ownerPid); + if (typeof observed === "string" && observed) { + return observed === owner.ownerIdentity ? "alive" : "dead"; + } + } catch { /* identity uncertainty fails closed */ } + return "unknown"; +} + +function makeOwner({ hostIdentity, ownerPid, ownerIdentity, now }) { + return { + version: 1, + token: randomUUID(), + hostIdentity, + ownerPid, + ownerIdentity, + workerState: "idle", + workerPid: null, + acquiredAt: now, + heartbeatAt: now, + }; +} + +function createLease({ cwd, ref, oid, owner, heartbeatMs }) { + const ownerToken = owner.token; + let currentOid = oid; + let currentOwner = owner; + let updateChain = Promise.resolve(); + let stopped = false; + let released = false; + let releasePromise = null; + let retained = false; + let lostError = null; + let interruptedError = null; + let interruptedTransition = null; + let resolveLost; + const lost = new Promise((resolve) => { resolveLost = resolve; }); + let heartbeatPending = false; + + const rememberLoss = (error) => { + if (lostError) return; + lostError = error; + resolveLost(error); + }; + const isInterruption = (error) => + error instanceof WorkspaceLockCancelledError || error instanceof WorkspaceLockDeadlineError; + + // State transitions accept the delegation's cancellation/deadline so a hung + // reference-transaction hook cannot pin a request past its advertised limit. + // An interruption leaves the ref's actual value unknown (git may have + // committed the CAS before being killed), so the lease stops updating and + // release() resyncs by owner token before deleting. + const queueUpdate = (change, interrupt = {}) => { + if (interruptedError) return Promise.reject(interruptedError); + updateChain = updateChain.then(async () => { + if (stopped || lostError || interruptedError) return; + const nextOwner = { ...currentOwner, ...change, heartbeatAt: Date.now() }; + const nextOid = await writeOwnerBlob(cwd, nextOwner, interrupt); + const previousOid = currentOid; + interruptedTransition = { previousOid, nextOid, nextOwner }; + if (!await compareAndSwap(cwd, ref, nextOid, previousOid, interrupt)) { + interruptedTransition = null; + throw new Error("workspace lock ownership changed during heartbeat"); + } + currentOwner = nextOwner; + currentOid = nextOid; + interruptedTransition = null; + }).catch((error) => { + if (isInterruption(error)) { + interruptedError ??= error; + return; + } + rememberLoss(error); + }); + return updateChain.then(() => { + if (interruptedError) throw interruptedError; + if (lostError) throw lostError; + }); + }; + + const queueOwnershipProbe = () => { + updateChain = updateChain.then(async () => { + if (stopped || lostError || interruptedError) return; + const observedOid = await readRefOid(cwd, ref); + if (observedOid !== currentOid) { + throw new Error("workspace lock ownership changed during heartbeat probe"); + } + }).catch((error) => { + if (isInterruption(error)) { + interruptedError ??= error; + return; + } + rememberLoss(error); + }); + return updateChain; + }; + + const timer = setInterval(() => { + if (stopped || heartbeatPending || lostError || interruptedError) return; + heartbeatPending = true; + // Process liveness and start-identity checks protect stale acquisition, so + // the periodic heartbeat only needs to prove this exact ref is still ours. + // A read-only probe avoids creating an unreachable content-addressed blob + // every few seconds during long delegations. + void queueOwnershipProbe().catch(() => {}).finally(() => { heartbeatPending = false; }); + }, heartbeatMs); + timer.unref?.(); + + return { + get ref() { return ref; }, + lost, + async assertOwned() { + await updateChain; + if (lostError) throw lostError; + if (interruptedError) throw interruptedError; + }, + async markWorkerStarting(interrupt = {}) { + await queueUpdate({ workerState: "starting", workerPid: null }, interrupt); + }, + async markWorkerRunning(pid, interrupt = {}) { + if (!Number.isInteger(pid) || pid <= 0) throw new Error("worker pid is unavailable"); + await queueUpdate({ workerState: "running", workerPid: pid }, interrupt); + }, + async markWorkerIdle(interrupt = {}) { + await queueUpdate({ workerState: "idle", workerPid: null }, interrupt); + }, + async markWorkerQuarantinePending(quarantineId) { + if (typeof quarantineId !== "string" || !quarantineId) { + throw new Error("quarantine id is unavailable"); + } + await queueUpdate({ + workerState: "quarantine-pending", quarantineMarkerPersisted: false, quarantineId, + }); + }, + async markWorkerQuarantined(quarantineId) { + if (typeof quarantineId !== "string" || !quarantineId) { + throw new Error("quarantine id is unavailable"); + } + await queueUpdate({ + workerState: "quarantined", quarantineMarkerPersisted: true, quarantineId, + }); + }, + retain() { + retained = true; + }, + async release() { + if (released) return; + if (releasePromise) return await releasePromise; + stopped = true; + clearInterval(timer); + releasePromise = (async () => { + await updateChain.catch(() => {}); + if (retained) { + if (lostError) throw lostError; + released = true; + return; + } + // If a state CAS was interrupted after Git committed it, the ref is + // exactly either the previous or candidate OID. Process both exact + // possibilities even when readback is temporarily unavailable; each + // one must become deleted, definitively non-current, durably + // authorized, or registered in this module's exact local fallback. + const candidateOwners = new Map([[currentOid, currentOwner]]); + if (interruptedTransition) { + candidateOwners.set(interruptedTransition.nextOid, interruptedTransition.nextOwner); + candidateOwners.set(interruptedTransition.previousOid, currentOwner); + } + if (interruptedError || interruptedTransition) { + try { + if (process.env.NODE_ENV === "test") { + const failures = Number( + process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_RELEASE_READBACK_FAILURES, + ); + if (Number.isInteger(failures) && failures > 0) { + process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_RELEASE_READBACK_FAILURES = + String(failures - 1); + throw new Error("fixture release readback failure"); + } + } + const observed = await readCurrentOwner(cwd, ref); + if (observed?.owner?.token === ownerToken) { + candidateOwners.set(observed.oid, observed.owner); + } + } catch { /* the exact candidate set remains sufficient */ } + } + let deleted = false; + const removalErrors = []; + for (const [candidateOid, candidateOwner] of candidateOwners) { + try { + const result = await removeOwnedRefWithRecovery( + cwd, ref, candidateOid, ownerToken, + ); + if (result.deleted) { + deleted = true; + break; + } + } catch (error) { + removalErrors.push(error); + } + } + if (deleted) { + for (const candidateOid of candidateOwners.keys()) { + clearExactLocalAbandonment(cwd, ref, candidateOid, ownerToken); + try { + const authorization = await readRecoveryAuthorization( + cwd, ref, candidateOid, ownerToken, + ); + await clearRecoveryAuthorization(cwd, authorization); + } catch { /* the owner ref is gone; stale exact authorization is harmless */ } + } + } else if (removalErrors.length > 0) { + throw removalErrors.length === 1 + ? removalErrors[0] + : new AggregateError(removalErrors, "cannot reconcile interrupted workspace lock state"); + } + released = true; + await maintainLockStore(cwd); + if (lostError) throw lostError; + if (!deleted) throw new Error("workspace lock ownership changed before release"); + })(); + try { + await releasePromise; + } finally { + releasePromise = null; + } + }, + }; +} + +export async function tryAcquireGitWorkspaceLock({ + cwd, + key, + cancel = null, + deadline = null, + staleMs = DEFAULT_STALE_MS, + heartbeatMs = DEFAULT_HEARTBEAT_MS, + hostIdentity = localHostIdentity(), + ownerPid = process.pid, + ownerIdentity = null, + now = Date.now(), + processProbe = probeProcess, + processIdentityProbe = null, + operatorRecoveryApproved = null, +} = {}) { + checkInterrupted(cancel, deadline); + const ref = workspaceLockRef(key); + const current = await readCurrentOwner(cwd, ref, { cancel, deadline }); + const abandonmentKey = current + ? localAbandonmentKey(cwd, ref, current.oid, current.owner?.token) + : null; + const localRecoveryAuthorized = abandonmentKey !== null && + locallyAbandonedRefs.has(abandonmentKey); + const recovery = current + ? await readRecoveryAuthorization( + cwd, ref, current.oid, current.owner?.token, { cancel, deadline }, + ) + : null; + const sharedRecoveryAuthorized = Boolean(recovery); + if (current && !sharedRecoveryAuthorized && !localRecoveryAuthorized && + !await canReclaim(current.owner, { + now, staleMs, hostIdentity, processProbe, processIdentityProbe, operatorRecoveryApproved, + })) { + return { acquired: false, reason: "held" }; + } + const owner = makeOwner({ hostIdentity, ownerPid, ownerIdentity, now }); + const newOid = await writeOwnerBlob(cwd, owner, { cancel, deadline }); + let acquired = false; + try { + if (!current) { + const zeroOid = "0".repeat(newOid.length); + checkInterrupted(cancel, deadline); + acquired = await compareAndSwap(cwd, ref, newOid, zeroOid, { cancel, deadline }); + } else { + checkInterrupted(cancel, deadline); + acquired = await compareAndSwap(cwd, ref, newOid, current.oid, { cancel, deadline }); + } + } catch (error) { + if (!(error instanceof WorkspaceLockCancelledError) && + !(error instanceof WorkspaceLockDeadlineError)) throw error; + let observed = null; + let readbackError = null; + try { + // The request token is already interrupted, so reconciliation uses only + // runGit's internal five-second bound. Never let a triggered token hide + // a CAS that Git committed before its process closed. + observed = await readRefOid(cwd, ref); + } catch (readError) { + readbackError = readError; + } + if (observed === newOid || readbackError) { + try { + await removeOwnedRefWithRecovery(cwd, ref, newOid, owner.token); + } catch (cleanupError) { + if (error.cause === undefined) { + error.cause = readbackError + ? new AggregateError([readbackError, cleanupError], "cannot reconcile interrupted acquisition") + : cleanupError; + } + } + } + throw error; + } + if (!acquired) return { acquired: false, reason: "contended" }; + try { + // This marker closes the attribution gap before any caller can inspect or + // modify the target repository. The owner ref remains visible until the + // marker is durable, so snapshots always observe at least one signal. + await writeRunHistory(cwd, ref, newOid, owner); + } catch (historyError) { + try { + await removeOwnedRefWithRecovery(cwd, ref, newOid, owner.token); + } catch (cleanupError) { + if (historyError.cause === undefined) historyError.cause = cleanupError; + } + throw historyError; + } + if (localRecoveryAuthorized) locallyAbandonedRefs.delete(abandonmentKey); + if (recovery) { + await clearRecoveryAuthorization(cwd, recovery); + } + const lease = createLease({ cwd, ref, oid: newOid, owner, heartbeatMs }); + try { + checkInterrupted(cancel, deadline); + } catch (error) { + try { + await lease.release(); + } catch (releaseError) { + // release() persisted authorization before its compensating delete, so + // another bridge process can recover the exact completed owner record. + if (error.cause === undefined) error.cause = releaseError; + } + throw error; + } + return { acquired: true, lease }; +} + +async function waitForRetry(cancel, deadline, pollMs) { + checkInterrupted(cancel, deadline); + const remaining = deadline === null ? pollMs : Math.min(pollMs, Math.max(1, deadline - Date.now())); + await new Promise((resolve) => { + let settled = false; + let unsubscribe = () => {}; + const done = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + unsubscribe(); + resolve(); + }; + const timer = setTimeout(done, remaining); + if (typeof cancel?.subscribe === "function") unsubscribe = cancel.subscribe(done); + else if (cancel?.promise) void cancel.promise.then(done); + }); + checkInterrupted(cancel, deadline); +} + +export async function acquireGitWorkspaceLock(options = {}) { + const pollMs = options.pollMs ?? DEFAULT_POLL_MS; + while (true) { + const result = await tryAcquireGitWorkspaceLock(options); + if (result.acquired) return result.lease; + await waitForRetry(options.cancel ?? null, options.deadline ?? null, pollMs); + } +}