From 02a992490a063d73ed65c4b2d492a825bd06f3bd Mon Sep 17 00:00:00 2001 From: Hylouis233 Date: Sat, 15 Aug 2026 17:03:43 +0800 Subject: [PATCH 01/40] Add plugin cli-agent-bridge 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. See NOTICE for upstream credits. --- plugins/Hylouis233/cli-agent-bridge/LICENSE | 21 + plugins/Hylouis233/cli-agent-bridge/NOTICE | 14 + plugins/Hylouis233/cli-agent-bridge/README.md | 89 ++++ .../Hylouis233/cli-agent-bridge/backends.json | 10 + plugins/Hylouis233/cli-agent-bridge/mcp.json | 10 + .../Hylouis233/cli-agent-bridge/plugin.json | 9 + .../Hylouis233/cli-agent-bridge/server.mjs | 452 ++++++++++++++++++ .../skills/cli-agent-bridge/SKILL.md | 44 ++ 8 files changed, 649 insertions(+) create mode 100644 plugins/Hylouis233/cli-agent-bridge/LICENSE create mode 100644 plugins/Hylouis233/cli-agent-bridge/NOTICE create mode 100644 plugins/Hylouis233/cli-agent-bridge/README.md create mode 100644 plugins/Hylouis233/cli-agent-bridge/backends.json create mode 100644 plugins/Hylouis233/cli-agent-bridge/mcp.json create mode 100644 plugins/Hylouis233/cli-agent-bridge/plugin.json create mode 100644 plugins/Hylouis233/cli-agent-bridge/server.mjs create mode 100644 plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md 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..3bd54df --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -0,0 +1,89 @@ +# 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 git diff the worker produced. + +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. +``` + +Expected result: two delegate_task runs (backend=claude and backend=kimi) against the same +workspace, followed by a comparison of the two diffs reported to the user. + +## 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. +- 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 | claude -p --output-format text | +| codex | OpenAI Codex CLI | verified | codex exec | +| kimi | Kimi Code | verified | kimi -p | +| zcode | ZCode | experimental | zcode -p (verify locally) | +| dsh | DeepSeek Harness | experimental | dsh run (verify locally) | + +Experimental backends ship with a sensible template that must be verified against your local +CLI version. Edit backends.json (or set the CLI_AGENT_BRIDGE_BACKENDS environment variable to a +custom file) to adjust command, args, or binary paths. + +## Data and network + +- This Plugin makes no network calls of its own and stores no credentials, tokens, or logs. +- 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 captures only the command output and the resulting git diff; nothing is transmitted + anywhere by the server itself. +- Workers run with whatever permission or sandbox configuration that CLI has. 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. + +## 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. +- zcode and dsh backends are experimental because their headless modes vary by version. + +## 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..028547c --- /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"], "resumeArgs": ["-p", "", "--output-format", "text", "--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 }, + "dsh": { "label": "DeepSeek Harness (dsh)", "command": "dsh", "buildArgs": ["run", ""], "resumeArgs": null, "experimental": true } + } +} 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/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs new file mode 100644 index 0000000..08f8117 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -0,0 +1,452 @@ +#!/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 { readFile, stat } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +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 MAX_CAPTURE_CHARS = 5_000_000; +const RAW_TAIL_CHARS = 60_000; + +// Built-in defaults. The sibling backends.json (or the CLI_AGENT_BRIDGE_BACKENDS +// environment variable) overrides these; a missing or invalid file falls back +// to this table. +const FALLBACK_BACKENDS = { + claude: { + label: "Claude Code", + command: "claude", + buildArgs: ["-p", "", "--output-format", "text"], + resumeArgs: ["-p", "", "--output-format", "text", "--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, + }, + dsh: { + label: "DeepSeek Harness (dsh)", + command: "dsh", + buildArgs: ["run", ""], + resumeArgs: null, + experimental: true, + }, +}; + +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. Read-only.", + 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: true, destructiveHint: false, idempotentHint: true, 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 diff stat and changed files produced by the run. Refuses to run when the working tree is dirty unless allowDirty=true.", + 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: "Execution timeout in milliseconds. Defaults to 1200000 (20 minutes).", + }, + }, + required: ["backend", "task", "workspacePath"], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + }, +]; + +async function loadBackends() { + const override = process.env.CLI_AGENT_BRIDGE_BACKENDS; + const candidates = []; + if (override) candidates.push(path.resolve(override)); + candidates.push(path.join(path.dirname(fileURLToPath(import.meta.url)), "backends.json")); + for (const file of candidates) { + try { + const raw = await readFile(file, "utf8"); + const parsed = JSON.parse(raw); + const backends = parsed && typeof parsed === "object" && parsed.backends && typeof parsed.backends === "object" + ? parsed.backends + : FALLBACK_BACKENDS; + if (Object.keys(backends).length > 0) return backends; + } catch { + // fall through to the next candidate + } + } + 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); + }); +} + +async function runCommand(command, args, options = {}) { + return await new Promise((resolve) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: process.env, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + let timedOut = false; + const timeoutMs = options.timeoutMs ?? 30_000; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + }, timeoutMs); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout = capAppend(stdout, chunk); }); + child.stderr.on("data", (chunk) => { stderr = capAppend(stderr, chunk); }); + + child.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ stdout, stderr, exitCode: null, timedOut, errorMessage: error.message }); + }); + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ stdout, stderr, exitCode: code, timedOut, errorMessage: "" }); + }); + }); +} + +function capAppend(current, chunk) { + const combined = current + chunk; + return combined.length > MAX_CAPTURE_CHARS ? combined.slice(-MAX_CAPTURE_CHARS) : combined; +} + +function tail(text, count) { + return text.length > count ? text.slice(-count) : text; +} + +async function validateWorkspace(workspacePath) { + if (typeof workspacePath !== "string" || !workspacePath.trim()) { + throw new Error("workspacePath must be a non-empty string"); + } + const resolved = path.resolve(workspacePath); + let stats; + try { + stats = await stat(resolved); + } catch (error) { + throw new Error("workspacePath does not exist: " + resolved); + } + if (!stats.isDirectory()) throw new Error("workspacePath must be a directory: " + resolved); + return resolved; +} + +async function requireGitRepo(workspacePath) { + const result = await runCommand("git", ["rev-parse", "--is-inside-work-tree"], { + cwd: workspacePath, timeoutMs: 15_000, + }); + if (result.exitCode !== 0 || result.stdout.trim() !== "true") { + throw new Error("workspacePath is not a git repository: " + workspacePath); + } +} + +async function gitSnapshot(workspacePath) { + const [status, diffStat, diffNames] = await Promise.all([ + runCommand("git", ["status", "--short"], { cwd: workspacePath, timeoutMs: 30_000 }), + runCommand("git", ["diff", "--stat"], { cwd: workspacePath, timeoutMs: 30_000 }), + runCommand("git", ["diff", "--name-only"], { cwd: workspacePath, timeoutMs: 30_000 }), + ]); + return { + statusShort: status.stdout.trim(), + diffStat: diffStat.stdout.trim(), + changedFiles: diffNames.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean), + }; +} + +async function listBackends() { + const backends = await loadBackends(); + const entries = []; + for (const [name, spec] of Object.entries(backends)) { + if (!spec || typeof spec.command !== "string") continue; + const check = await runCommand(spec.command, ["--version"], { timeoutMs: VERSION_CHECK_TIMEOUT_MS }); + entries.push({ + name, + label: typeof spec.label === "string" ? spec.label : name, + command: spec.command, + available: check.exitCode === 0, + experimental: Boolean(spec.experimental), + version: check.exitCode === 0 ? tail(check.stdout, 200).trim() : null, + error: check.exitCode === 0 ? "" : (check.errorMessage || "command not found or not executable"), + resumeSupported: Array.isArray(spec.resumeArgs), + }); + } + return entries; +} + +async function delegateTask(rawArgs) { + const backends = await loadBackends(); + if (!rawArgs || typeof rawArgs.backend !== "string" || !rawArgs.backend.trim()) { + throw new Error("backend must be a non-empty string"); + } + const backend = rawArgs.backend.trim(); + const spec = backends[backend]; + if (!spec || typeof spec.command !== "string") { + throw new Error("unknown backend \"" + backend + "\"; use list_backends to see configured backends"); + } + if (typeof rawArgs.task !== "string" || !rawArgs.task.trim()) { + throw new Error("task must be a non-empty string"); + } + const workspacePath = await validateWorkspace(rawArgs.workspacePath); + await requireGitRepo(workspacePath); + + const allowDirty = rawArgs.allowDirty === true; + const before = await gitSnapshot(workspacePath); + if (!allowDirty && before.statusShort) { + return { + ok: false, + error: "working tree is dirty; review current changes first or set allowDirty=true deliberately", + backend, workspacePath, exitCode: null, timedOut: false, outputTail: "", stderrTail: "", + git: before, experimental: Boolean(spec.experimental), + }; + } + + let template; + if (typeof rawArgs.resumeSessionId === "string" && rawArgs.resumeSessionId.trim() && Array.isArray(spec.resumeArgs)) { + 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, exitCode: null, timedOut: false, outputTail: "", stderrTail: "", + git: before, experimental: Boolean(spec.experimental), + }; + } + + const timeoutMs = Number.isInteger(rawArgs.timeoutMs) + ? Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, rawArgs.timeoutMs)) + : DEFAULT_TIMEOUT_MS; + const args = substituteArgs(template, rawArgs.task.trim(), rawArgs.resumeSessionId ?? ""); + + const result = await runCommand(spec.command, args, { cwd: workspacePath, timeoutMs }); + const after = await gitSnapshot(workspacePath); + let error = ""; + if (result.timedOut) error = "backend \"" + backend + "\" timed out after " + timeoutMs + " ms"; + else if (result.exitCode !== 0) error = "backend \"" + backend + "\" exited with code " + String(result.exitCode); + + return { + ok: !error, + error, + backend, + workspacePath, + exitCode: result.exitCode, + timedOut: result.timedOut, + outputTail: tail(result.stdout, RAW_TAIL_CHARS), + stderrTail: tail(result.stderr, RAW_TAIL_CHARS), + git: after, + experimental: Boolean(spec.experimental), + }; +} + +function textResult(header, obj) { + const lines = ["# " + header, ""]; + for (const [key, value] of Object.entries(obj)) { + if (key === "outputTail" || key === "stderrTail" || key === "git") continue; + lines.push("- " + key + ": " + String(value ?? "")); + } + if (obj.git) { + lines.push("", "## git status --short", "", "~~~text", obj.git.statusShort || "(clean)", "~~~"); + lines.push("", "## git diff --stat", "", "~~~text", obj.git.diffStat || "(empty)", "~~~"); + lines.push("", "## changed files", "", "~~~text", (obj.git.changedFiles ?? []).join("\n") || "(none)", "~~~"); + } + 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 } }; } + +async function handleMessage(message) { + if (!message || typeof message !== "object" || message.jsonrpc !== "2.0") { + return jsonRpcError(null, -32600, "Invalid JSON-RPC request"); + } + if (message.id === undefined) return null; // notification + + try { + switch (message.method) { + case "initialize": + return jsonRpcResult(message.id, { + protocolVersion: message.params?.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 diff. 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 entries = await listBackends(); + 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); + } + return jsonRpcResult(message.id, { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: { backends: entries }, + }); + } + if (params.name === "workspace_status") { + const workspacePath = await validateWorkspace(args.workspacePath); + await requireGitRepo(workspacePath); + const git = await gitSnapshot(workspacePath); + return jsonRpcResult(message.id, { + content: [{ type: "text", text: textResult("Workspace Status", { workspacePath, git }) }], + structuredContent: { ok: true, workspacePath, git }, + }); + } + if (params.name === "delegate_task") { + const out = await delegateTask(args); + return jsonRpcResult(message.id, { + content: [{ type: "text", text: textResult("Delegated Task Result", out) }], + structuredContent: out, + }); + } + 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, -32603, error.message); + } +} + +function startStdioServer({ stdin = process.stdin, stdout = process.stdout } = {}) { + stdin.setEncoding("utf8"); + let buffer = ""; + stdin.on("data", (chunk) => { + buffer += chunk; + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + 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 (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) { + startStdioServer(); +} + 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..fb0f0ad --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -0,0 +1,44 @@ +--- +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 can run in parallel across different CLIs. +- 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. +4. Review the returned git status, diff stat, changed files, output tail, and exit code. +5. If the result is wrong, delegate a follow-up task with resumeSessionId where supported. + +## 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. +- Review every change the worker produced before reporting completion. +- Timeouts: the default is 20 minutes; adjust timeoutMs for very large tasks. + +## 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. From 9cd9011c67acf93e498425bf9f1c07a067606dbb Mon Sep 17 00:00:00 2001 From: Hylouis233 Date: Sat, 15 Aug 2026 17:35:43 +0800 Subject: [PATCH 02/40] Harden cli-agent-bridge headless templates and git snapshot - claude template gains --permission-mode acceptEdits so headless runs can actually edit files (verified end-to-end with Claude Code 2.1.226) - kimi keeps plain -p prompt mode: --auto/-y are mutually exclusive with -p on current Kimi Code versions - git snapshot now lists untracked files (git ls-files --others) so new files created by workers appear in changed files - README/SKILL document permission defaults and honest backend verification status --- plugins/Hylouis233/cli-agent-bridge/README.md | 36 +++++++++++++------ .../Hylouis233/cli-agent-bridge/backends.json | 6 ++-- .../Hylouis233/cli-agent-bridge/server.mjs | 20 ++++++++--- .../skills/cli-agent-bridge/SKILL.md | 5 ++- 4 files changed, 47 insertions(+), 20 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 3bd54df..0bdd6a2 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -47,15 +47,27 @@ workspace, followed by a comparison of the two diffs reported to the user. | Backend | CLI | Status | Headless form used | |---|---|---|---| -| claude | Claude Code | verified | claude -p --output-format text | -| codex | OpenAI Codex CLI | verified | codex exec | -| kimi | Kimi Code | verified | kimi -p | -| zcode | ZCode | experimental | zcode -p (verify locally) | -| dsh | DeepSeek Harness | experimental | dsh run (verify locally) | +| 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 | -Experimental backends ship with a sensible template that must be verified against your local -CLI version. Edit backends.json (or set the CLI_AGENT_BRIDGE_BACKENDS environment variable to a -custom file) to adjust command, args, or binary paths. +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. ## Data and network @@ -66,8 +78,9 @@ custom file) to adjust command, args, or binary paths. credentials, private endpoints, or personal data in a task. - The server captures only the command output and the resulting git diff; nothing is transmitted anywhere by the server itself. -- Workers run with whatever permission or sandbox configuration that CLI has. Review the - returned diff before accepting the work. +- 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 @@ -82,7 +95,8 @@ change the command field. resumeSessionId is honored only for backends whose res plays that role instead. - The bridge delegates tasks; it does not merge code, commit, or push. The user reviews every diff. -- zcode and dsh backends are experimental because their headless modes vary by version. +- 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. ## License diff --git a/plugins/Hylouis233/cli-agent-bridge/backends.json b/plugins/Hylouis233/cli-agent-bridge/backends.json index 028547c..5836f86 100644 --- a/plugins/Hylouis233/cli-agent-bridge/backends.json +++ b/plugins/Hylouis233/cli-agent-bridge/backends.json @@ -1,10 +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"], "resumeArgs": ["-p", "", "--output-format", "text", "--resume", ""], "experimental": false }, + "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 }, - "dsh": { "label": "DeepSeek Harness (dsh)", "command": "dsh", "buildArgs": ["run", ""], "resumeArgs": null, "experimental": true } + "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/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 08f8117..3bf5003 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -28,8 +28,8 @@ const FALLBACK_BACKENDS = { claude: { label: "Claude Code", command: "claude", - buildArgs: ["-p", "", "--output-format", "text"], - resumeArgs: ["-p", "", "--output-format", "text", "--resume", ""], + buildArgs: ["-p", "", "--output-format", "text", "--permission-mode", "acceptEdits"], + resumeArgs: ["-p", "", "--output-format", "text", "--permission-mode", "acceptEdits", "--resume", ""], experimental: false, }, codex: { @@ -52,13 +52,15 @@ const FALLBACK_BACKENDS = { 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: ["run", ""], + buildArgs: ["--profile", "headless", ""], resumeArgs: null, experimental: true, + notes: "Uses the documented headless profile; requires a headless profile under DSH_HOME/profiles.", }, }; @@ -238,15 +240,21 @@ async function requireGitRepo(workspacePath) { } async function gitSnapshot(workspacePath) { - const [status, diffStat, diffNames] = await Promise.all([ + const [status, diffStat, diffNames, untracked] = await Promise.all([ runCommand("git", ["status", "--short"], { cwd: workspacePath, timeoutMs: 30_000 }), runCommand("git", ["diff", "--stat"], { cwd: workspacePath, timeoutMs: 30_000 }), runCommand("git", ["diff", "--name-only"], { cwd: workspacePath, timeoutMs: 30_000 }), + runCommand("git", ["ls-files", "--others", "--exclude-standard"], { cwd: workspacePath, timeoutMs: 30_000 }), ]); + const seen = new Set(); + const changedFiles = [ + ...diffNames.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean), + ...untracked.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean), + ].filter((f) => (seen.has(f) ? false : (seen.add(f), true))); return { statusShort: status.stdout.trim(), diffStat: diffStat.stdout.trim(), - changedFiles: diffNames.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean), + changedFiles, }; } @@ -265,6 +273,7 @@ async function listBackends() { version: check.exitCode === 0 ? tail(check.stdout, 200).trim() : null, error: check.exitCode === 0 ? "" : (check.errorMessage || "command not found or not executable"), resumeSupported: Array.isArray(spec.resumeArgs), + notes: typeof spec.notes === "string" ? spec.notes : "", }); } return entries; @@ -387,6 +396,7 @@ async function handleMessage(message) { 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") }], 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 index fb0f0ad..8df892a 100644 --- a/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -35,7 +35,10 @@ inside the target git repository, and their results come back as a git diff for - 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. -- Review every change the worker produced before reporting completion. +- 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. - Timeouts: the default is 20 minutes; adjust timeoutMs for very large tasks. ## Notes From 78515cf5b2666d16a80fad1704d99562cb8233cb Mon Sep 17 00:00:00 2001 From: Hylouis233 Date: Sat, 15 Aug 2026 20:54:02 +0800 Subject: [PATCH 03/40] Launch Windows command shims through PowerShell runner npm-style .ps1/.cmd shims cannot be spawned directly on Windows, so runCommand retries them through the bundled ps1-runner.ps1 (Windows PowerShell 5.1) with verbatim argument forwarding. README documents the fallback and the custom-wrapper caveat. --- plugins/Hylouis233/cli-agent-bridge/README.md | 8 ++++ .../cli-agent-bridge/ps1-runner.ps1 | 8 ++++ .../Hylouis233/cli-agent-bridge/server.mjs | 46 +++++++++++++++---- 3 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 0bdd6a2..7ede597 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -69,6 +69,12 @@ your ZCode distribution provides one. The dsh template uses its documented headl the CLI_AGENT_BRIDGE_BACKENDS environment variable to a custom file) to adjust command, args, or binary paths. +On Windows, npm-style .ps1/.cmd shims cannot be launched directly, so the server retries them +through the bundled ps1-runner.ps1 using the built-in Windows PowerShell 5.1. Arguments pass +through verbatim with no cmd.exe re-interpretation. If a custom wrapper shim re-binds parameters +(for example a proxy autostart shim) and mangles dashed flags, set the backend command to the +underlying real executable in backends.json. + ## Data and network - This Plugin makes no network calls of its own and stores no credentials, tokens, or logs. @@ -97,6 +103,8 @@ change the command field. resumeSessionId is honored only for backends whose res diff. - 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. ## License 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..87d6f6b --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 @@ -0,0 +1,8 @@ +# 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. +$Command = $args[0] +$rest = $args | Select-Object -Skip 1 +& $Command @rest +exit $LASTEXITCODE + diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 3bf5003..2e4230e 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -169,16 +169,24 @@ function substituteArgs(template, task, session) { } async function runCommand(command, args, options = {}) { - return await new Promise((resolve) => { - const child = spawn(command, args, { - cwd: options.cwd, - env: process.env, - windowsHide: true, - stdio: ["ignore", "pipe", "pipe"], - }); + const spawnOnce = (argv, shellArgs) => new Promise((resolve) => { + const child = shellArgs + ? spawn(shellArgs[0], shellArgs.slice(1), { + cwd: options.cwd, + env: process.env, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }) + : spawn(command, argv, { + cwd: options.cwd, + env: process.env, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); let stdout = ""; let stderr = ""; let settled = false; + let spawnError = null; let timedOut = false; const timeoutMs = options.timeoutMs ?? 30_000; const timer = setTimeout(() => { @@ -192,18 +200,38 @@ async function runCommand(command, args, options = {}) { child.stderr.on("data", (chunk) => { stderr = capAppend(stderr, chunk); }); child.on("error", (error) => { + spawnError = error; if (settled) return; settled = true; clearTimeout(timer); - resolve({ stdout, stderr, exitCode: null, timedOut, errorMessage: error.message }); + resolve({ stdout, stderr, exitCode: null, timedOut, errorMessage: error.message, spawnError }); }); child.on("close", (code) => { if (settled) return; settled = true; clearTimeout(timer); - resolve({ stdout, stderr, exitCode: code, timedOut, errorMessage: "" }); + resolve({ stdout, stderr, exitCode: code, timedOut, errorMessage: "", spawnError }); }); }); + + const direct = await spawnOnce(args, null); + if (process.platform !== "win32" || !direct.spawnError) 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, [ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + runner, + command, + ...args, + ]); } function capAppend(current, chunk) { From 77b9cb252dc1927653d643282591d7d8ea7964e6 Mon Sep 17 00:00:00 2001 From: Hylouis233 Date: Sat, 15 Aug 2026 20:58:50 +0800 Subject: [PATCH 04/40] Address PR review: serialize, cancel, fail-closed, honest results - per-workspace serialization of delegate_task - SIGTERM then SIGKILL force kill after timeout grace - snapshots include staged, untracked files and committed deltas - delegate_task marked destructiveHint, isError on failures - notifications/cancelled kills the worker - honest protocol version negotiation - fail closed when git snapshot commands fail - ring-buffer output capture without repeated large copies - README discloses supported operating systems --- plugins/Hylouis233/cli-agent-bridge/README.md | 4 + .../Hylouis233/cli-agent-bridge/server.mjs | 337 +++++++++++++----- 2 files changed, 259 insertions(+), 82 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 7ede597..dde3ad5 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -43,6 +43,10 @@ workspace, followed by a comparison of the two diffs reported to the user. - 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. +- Supported operating systems: Windows, macOS, and Linux. The server is plain Node.js; on Windows, + shim-based CLIs additionally go through the bundled PowerShell 5.1 runner. End-to-end verified on + Windows (Claude Code 2.1.226, Kimi Code 0.30.0) and validated on Linux in a Node 22 container; + macOS uses the same POSIX path and is not yet machine-verified. - Each backend CLI must be installed, on PATH, and signed in with your own account before use: | Backend | CLI | Status | Headless form used | diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 2e4230e..a4c89e3 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -18,6 +18,8 @@ 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 GIT_TIMEOUT_MS = 30_000; +const KILL_GRACE_MS = 10_000; const MAX_CAPTURE_CHARS = 5_000_000; const RAW_TAIL_CHARS = 60_000; @@ -96,7 +98,7 @@ const TOOLS = [ 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 diff stat and changed files produced by the run. Refuses to run when the working tree is dirty unless allowDirty=true.", + "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. Delegations to the same workspace are serialized.", inputSchema: { type: "object", additionalProperties: false, @@ -131,12 +133,12 @@ const TOOLS = [ minimum: MIN_TIMEOUT_MS, maximum: MAX_TIMEOUT_MS, default: DEFAULT_TIMEOUT_MS, - description: "Execution timeout in milliseconds. Defaults to 1200000 (20 minutes).", + description: "Execution timeout in milliseconds. Defaults to 1200000 (20 minutes). After the timeout the worker receives SIGTERM, then a forceful kill after a 10 second grace period.", }, }, required: ["backend", "task", "workspacePath"], }, - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, }, ]; @@ -168,6 +170,25 @@ function substituteArgs(template, task, session) { }); } +// 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() { + let chunks = []; + let length = 0; + return { + push(chunk) { + if (typeof chunk !== "string" || 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; + } + }, + text() { return chunks.join(""); }, + }; +} + async function runCommand(command, args, options = {}) { const spawnOnce = (argv, shellArgs) => new Promise((resolve) => { const child = shellArgs @@ -183,34 +204,77 @@ async function runCommand(command, args, options = {}) { windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); - let stdout = ""; - let stderr = ""; + if (typeof options.onChild === "function") options.onChild(child); + const stdoutBuf = capture(); + const stderrBuf = capture(); let settled = false; let spawnError = null; let timedOut = false; + let killed = false; const timeoutMs = options.timeoutMs ?? 30_000; + const settle = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(forceTimer); + resolve({ + stdout: stdoutBuf.text(), + stderr: stderrBuf.text(), + exitCode: null, + timedOut, + killed, + errorMessage: "", + spawnError, + }); + }; const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, timeoutMs); + // If the child ignores SIGTERM or a descendant holds the pipes, close never + // fires; force-settle after the grace period so the MCP call cannot hang. + const forceTimer = setTimeout(() => { + if (settled) return; + killed = true; + try { child.kill("SIGKILL"); } catch { /* already gone */ } + settle(); + }, timeoutMs + KILL_GRACE_MS); child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { stdout = capAppend(stdout, chunk); }); - child.stderr.on("data", (chunk) => { stderr = capAppend(stderr, chunk); }); + child.stdout.on("data", (chunk) => { stdoutBuf.push(chunk); }); + child.stderr.on("data", (chunk) => { stderrBuf.push(chunk); }); child.on("error", (error) => { spawnError = error; if (settled) return; settled = true; clearTimeout(timer); - resolve({ stdout, stderr, exitCode: null, timedOut, errorMessage: error.message, spawnError }); + clearTimeout(forceTimer); + resolve({ + stdout: stdoutBuf.text(), + stderr: stderrBuf.text(), + exitCode: null, + timedOut, + killed, + errorMessage: error.message, + spawnError, + }); }); child.on("close", (code) => { if (settled) return; settled = true; clearTimeout(timer); - resolve({ stdout, stderr, exitCode: code, timedOut, errorMessage: "", spawnError }); + clearTimeout(forceTimer); + resolve({ + stdout: stdoutBuf.text(), + stderr: stderrBuf.text(), + exitCode: code, + timedOut, + killed, + errorMessage: "", + spawnError, + }); }); }); @@ -234,11 +298,6 @@ async function runCommand(command, args, options = {}) { ]); } -function capAppend(current, chunk) { - const combined = current + chunk; - return combined.length > MAX_CAPTURE_CHARS ? combined.slice(-MAX_CAPTURE_CHARS) : combined; -} - function tail(text, count) { return text.length > count ? text.slice(-count) : text; } @@ -267,22 +326,62 @@ async function requireGitRepo(workspacePath) { } } +function snapshotFailure(label, result) { + if (result.timedOut) return label + " timed out"; + if (result.exitCode !== 0) return label + " failed with exit code " + String(result.exitCode); + return ""; +} + async function gitSnapshot(workspacePath) { - const [status, diffStat, diffNames, untracked] = await Promise.all([ - runCommand("git", ["status", "--short"], { cwd: workspacePath, timeoutMs: 30_000 }), - runCommand("git", ["diff", "--stat"], { cwd: workspacePath, timeoutMs: 30_000 }), - runCommand("git", ["diff", "--name-only"], { cwd: workspacePath, timeoutMs: 30_000 }), - runCommand("git", ["ls-files", "--others", "--exclude-standard"], { cwd: workspacePath, timeoutMs: 30_000 }), - ]); + const jobs = [ + ["git status --short", "status", ["status", "--short"]], + ["git diff --stat", "diffStat", ["diff", "--stat"]], + ["git diff --name-only", "diffNames", ["diff", "--name-only"]], + ["git diff --cached --stat", "cachedDiffStat", ["diff", "--cached", "--stat"]], + ["git diff --cached --name-only", "cachedDiffNames", ["diff", "--cached", "--name-only"]], + ["git ls-files --others --exclude-standard", "untracked", ["ls-files", "--others", "--exclude-standard"]], + ["git rev-parse HEAD", "head", ["rev-parse", "HEAD"]], + ]; + const results = await Promise.all(jobs.map((j) => runCommand("git", j[2], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }))); + const failures = []; + const out = {}; + results.forEach((result, i) => { + const failure = snapshotFailure(jobs[i][0], result); + if (failure) { failures.push(failure); return; } + out[jobs[i][1]] = result.stdout; + }); + if (failures.length > 0) { + // Fail closed: an unreliable snapshot must never authorize a delegation. + throw new Error("git snapshot unreliable: " + failures.join("; ")); + } const seen = new Set(); const changedFiles = [ - ...diffNames.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean), - ...untracked.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean), + ...String(out.diffNames ?? "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean), + ...String(out.cachedDiffNames ?? "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean), + ...String(out.untracked ?? "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean), ].filter((f) => (seen.has(f) ? false : (seen.add(f), true))); + const diffStat = [String(out.diffStat ?? "").trim(), String(out.cachedDiffStat ?? "").trim()] + .filter(Boolean) + .map((s, i) => (i === 0 ? s : s.split(/\r?\n/).map((l) => "staged: " + l).join("\n"))) + .join("\n"); return { - statusShort: status.stdout.trim(), - diffStat: diffStat.stdout.trim(), + statusShort: String(out.status ?? "").trim(), + diffStat, changedFiles, + head: String(out.head ?? "").trim(), + }; +} + +async function committedDelta(workspacePath, beforeHead, afterHead) { + if (!beforeHead || beforeHead === afterHead) return null; + const [log, stat] = await Promise.all([ + runCommand("git", ["log", "--oneline", beforeHead + ".." + afterHead], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }), + runCommand("git", ["diff", "--stat", beforeHead + ".." + afterHead], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }), + ]); + return { + range: beforeHead + ".." + afterHead, + log: String(log.stdout ?? "").trim(), + diffStat: String(stat.stdout ?? "").trim(), }; } @@ -307,7 +406,25 @@ async function listBackends() { return entries; } -async function delegateTask(rawArgs) { +// Per-workspace mutex: concurrent delegations to the same checkout are +// serialized so workers cannot interleave edits or snapshot each other. +const workspaceLocks = new Map(); +async function withWorkspaceLock(key, fn) { + const prev = workspaceLocks.get(key) ?? Promise.resolve(); + let release; + const gate = new Promise((r) => { release = r; }); + const next = prev.catch(() => {}).then(() => gate); + workspaceLocks.set(key, next); + await prev.catch(() => {}); + try { + return await fn(); + } finally { + release(); + if (workspaceLocks.get(key) === next) workspaceLocks.delete(key); + } +} + +async function delegateTask(rawArgs, cancel) { const backends = await loadBackends(); if (!rawArgs || typeof rawArgs.backend !== "string" || !rawArgs.backend.trim()) { throw new Error("backend must be a non-empty string"); @@ -323,66 +440,94 @@ async function delegateTask(rawArgs) { const workspacePath = await validateWorkspace(rawArgs.workspacePath); await requireGitRepo(workspacePath); - const allowDirty = rawArgs.allowDirty === true; - const before = await gitSnapshot(workspacePath); - if (!allowDirty && before.statusShort) { - return { - ok: false, - error: "working tree is dirty; review current changes first or set allowDirty=true deliberately", - backend, workspacePath, exitCode: null, timedOut: false, outputTail: "", stderrTail: "", - git: before, experimental: Boolean(spec.experimental), - }; - } + return await withWorkspaceLock("ws:" + workspacePath, async () => { + const allowDirty = rawArgs.allowDirty === true; + const before = await gitSnapshot(workspacePath); + if (!allowDirty && before.statusShort) { + return { + ok: false, + error: "working tree is dirty; review current changes first or set allowDirty=true deliberately", + backend, workspacePath, exitCode: null, timedOut: false, killed: false, cancelled: false, + outputTail: "", stderrTail: "", + gitBefore: before, git: before, commits: null, + experimental: Boolean(spec.experimental), + }; + } - let template; - if (typeof rawArgs.resumeSessionId === "string" && rawArgs.resumeSessionId.trim() && Array.isArray(spec.resumeArgs)) { - 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, exitCode: null, timedOut: false, outputTail: "", stderrTail: "", - git: before, experimental: Boolean(spec.experimental), - }; - } + let template; + if (typeof rawArgs.resumeSessionId === "string" && rawArgs.resumeSessionId.trim() && Array.isArray(spec.resumeArgs)) { + 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, exitCode: null, timedOut: false, killed: false, cancelled: false, + outputTail: "", stderrTail: "", + gitBefore: before, git: before, commits: null, + experimental: Boolean(spec.experimental), + }; + } - const timeoutMs = Number.isInteger(rawArgs.timeoutMs) - ? Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, rawArgs.timeoutMs)) - : DEFAULT_TIMEOUT_MS; - const args = substituteArgs(template, rawArgs.task.trim(), rawArgs.resumeSessionId ?? ""); + const timeoutMs = Number.isInteger(rawArgs.timeoutMs) + ? Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, rawArgs.timeoutMs)) + : DEFAULT_TIMEOUT_MS; + const args = substituteArgs(template, rawArgs.task.trim(), rawArgs.resumeSessionId ?? ""); - const result = await runCommand(spec.command, args, { cwd: workspacePath, timeoutMs }); - const after = await gitSnapshot(workspacePath); - let error = ""; - if (result.timedOut) error = "backend \"" + backend + "\" timed out after " + timeoutMs + " ms"; - else if (result.exitCode !== 0) error = "backend \"" + backend + "\" exited with code " + String(result.exitCode); + const result = await runCommand(spec.command, args, { + cwd: workspacePath, + timeoutMs, + onChild: (child) => { if (cancel) cancel.child = child; }, + }); + const after = await gitSnapshot(workspacePath); + const commits = await committedDelta(workspacePath, before.head, after.head); + let error = ""; + if (cancel && cancel.cancelled) { + error = "delegation cancelled by client"; + } else if (result.timedOut) { + error = "backend \"" + backend + "\" timed out after " + timeoutMs + " ms" + (result.killed ? " and was force-killed" : ""); + } else if (result.exitCode !== 0) { + error = "backend \"" + backend + "\" exited with code " + String(result.exitCode); + } - return { - ok: !error, - error, - backend, - workspacePath, - exitCode: result.exitCode, - timedOut: result.timedOut, - outputTail: tail(result.stdout, RAW_TAIL_CHARS), - stderrTail: tail(result.stderr, RAW_TAIL_CHARS), - git: after, - experimental: Boolean(spec.experimental), - }; + return { + ok: !error, + error, + backend, + workspacePath, + exitCode: result.exitCode, + timedOut: result.timedOut, + killed: result.killed, + cancelled: Boolean(cancel && cancel.cancelled), + outputTail: tail(result.stdout, RAW_TAIL_CHARS), + stderrTail: tail(result.stderr, RAW_TAIL_CHARS), + gitBefore: before, + git: after, + commits, + experimental: Boolean(spec.experimental), + }; + }); } function textResult(header, obj) { const lines = ["# " + header, ""]; for (const [key, value] of Object.entries(obj)) { - if (key === "outputTail" || key === "stderrTail" || key === "git") continue; + if (["outputTail", "stderrTail", "git", "gitBefore", "commits"].includes(key)) continue; lines.push("- " + key + ": " + String(value ?? "")); } - if (obj.git) { - lines.push("", "## git status --short", "", "~~~text", obj.git.statusShort || "(clean)", "~~~"); - lines.push("", "## git diff --stat", "", "~~~text", obj.git.diffStat || "(empty)", "~~~"); - lines.push("", "## changed files", "", "~~~text", (obj.git.changedFiles ?? []).join("\n") || "(none)", "~~~"); + 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 || "(unknown)", "~~~"); + }; + gitBlock("before", obj.gitBefore); + gitBlock("after", obj.git); + if (obj.commits) { + lines.push("", "## commits made by the worker", "", "~~~text", obj.commits.log || "(none)", "~~~"); + lines.push("", "## commit diff stat", "", "~~~text", obj.commits.diffStat || "(empty)", "~~~"); } if (obj.outputTail) lines.push("", "## output tail", "", "~~~text", obj.outputTail, "~~~"); if (obj.stderrTail) lines.push("", "## stderr tail", "", "~~~text", obj.stderrTail, "~~~"); @@ -393,21 +538,42 @@ function textResult(header, obj) { 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 delegate_task requests, keyed by JSON-RPC request id, so a +// notifications/cancelled can terminate the worker process. +const activeRequests = new Map(); + async function handleMessage(message) { if (!message || typeof message !== "object" || message.jsonrpc !== "2.0") { return jsonRpcError(null, -32600, "Invalid JSON-RPC request"); } - if (message.id === undefined) return null; // notification + if (message.method === "notifications/cancelled") { + const requestId = message.params?.requestId ?? message.params?.id; + const entry = activeRequests.get(String(requestId)); + if (entry && entry.cancel) { + entry.cancel.cancelled = true; + if (entry.cancel.child) { + try { entry.cancel.child.kill("SIGTERM"); } catch { /* already gone */ } + setTimeout(() => { + try { entry.cancel.child.kill("SIGKILL"); } catch { /* already gone */ } + }, KILL_GRACE_MS).unref?.(); + } + } + return null; + } + if (message.id === undefined) return null; // other notification try { switch (message.method) { case "initialize": return jsonRpcResult(message.id, { - protocolVersion: message.params?.protocolVersion ?? PROTOCOL_VERSION, + // 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 diff. Never put credentials in task text.", + "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, {}); @@ -441,11 +607,18 @@ async function handleMessage(message) { }); } if (params.name === "delegate_task") { - const out = await delegateTask(args); - return jsonRpcResult(message.id, { - content: [{ type: "text", text: textResult("Delegated Task Result", out) }], - structuredContent: out, - }); + const cancel = { child: null, cancelled: false }; + activeRequests.set(String(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 { + activeRequests.delete(String(message.id)); + } } return jsonRpcError(message.id, -32602, "Unknown tool: " + params.name); } From db2553ede7b1cdee15afe64d1d382324e83380fe Mon Sep 17 00:00:00 2001 From: Hylouis233 Date: Sun, 16 Aug 2026 00:35:51 +0800 Subject: [PATCH 05/40] Align docs with review-hardened server behavior SKILL workflow and README tool description now cover before/after snapshots, committed deltas, same-workspace serialization, isError semantics, force-kill timeouts, and cancellation. --- plugins/Hylouis233/cli-agent-bridge/README.md | 4 +++- .../skills/cli-agent-bridge/SKILL.md | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index dde3ad5..75086eb 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -16,7 +16,9 @@ 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 git diff the worker produced. + 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; dirty trees are refused unless allowDirty=true; cancellation kills the worker. The Skill teaches MiniMax Code when and how to delegate, and to review the returned diff before reporting completion. 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 index 8df892a..610e06a 100644 --- a/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -20,7 +20,12 @@ inside the target git repository, and their results come back as a git diff for 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. -4. Review the returned git status, diff stat, changed files, output tail, and exit code. + Delegations to the same workspace are serialized by the server, so parallel runs on one + checkout queue instead of interleaving edits. +4. Review the returned result: the before and after git snapshots (status, diff stat, changed + files including staged and new files), 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. 5. If the result is wrong, delegate a follow-up task with resumeSessionId where supported. ## Backend guidance @@ -39,7 +44,12 @@ inside the target git repository, and their results come back as a git diff for 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. -- Timeouts: the default is 20 minutes; adjust timeoutMs for very large tasks. +- Timeouts: the default is 20 minutes; adjust timeoutMs for very large tasks. A timed-out worker + is terminated (SIGTERM, then a forceful kill after a grace period), so a delegation call never + hangs past the cap. +- Cancellation: cancelling an in-flight delegate_task call terminates the worker process and the + result reports cancelled=true; the workspace may still contain the edits the worker made before + cancellation, so still review the returned snapshot. ## Notes From fd9c432f3bb916311d5d9bfa64adce2a3fccdb2e Mon Sep 17 00:00:00 2001 From: Hylouis233 Date: Sun, 16 Aug 2026 00:36:56 +0800 Subject: [PATCH 06/40] Add plugin test suite and precise parallel wording Seven self-contained tests cover protocol negotiation, tool list, snapshots, dirty-tree guard, unknown backends, cancellation, and before/after snapshots. SKILL wording now notes same-workspace serialization. --- .../skills/cli-agent-bridge/SKILL.md | 3 +- .../cli-agent-bridge/test/server.test.mjs | 150 ++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs 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 index 610e06a..0f2103c 100644 --- a/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -12,7 +12,8 @@ inside the target git repository, and their results come back as a git diff for - 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 can run in parallel across different CLIs. +- Independent subtasks in separate workspaces can run in parallel across different CLIs; + same-workspace delegations queue behind each other. - The user wants a second opinion or a cross-check from another agent. ## Workflow 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..2f25a7e --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs @@ -0,0 +1,150 @@ +// 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. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { execSync } from "node:child_process"; +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, ...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); + } + } + }); + 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 = () => child.kill(); + return { child, rpc, notify, stop }; +} + +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; +} + +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 () => { + const s = startServer(); + await s.rpc(1, "initialize", {}); + 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); + s.stop(); +}); + +test("workspace_status reports changed files including untracked ones", async () => { + const s = startServer(); + await s.rpc(1, "initialize", {}); + 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")); + s.stop(); +}); + +test("delegate_task refuses a dirty tree without allowDirty and sets isError", async () => { + const s = startServer(); + await s.rpc(1, "initialize", {}); + const repo = makeRepo(); + writeFileSync(path.join(repo, "dirty.txt"), "dirty"); + const res = await s.rpc(2, "tools/call", { name: "delegate_task", arguments: { backend: "claude", task: "x", workspacePath: repo } }); + assert.equal(res.result.structuredContent.ok, false); + assert.equal(res.result.isError, true); + assert.match(res.result.structuredContent.error, /dirty/); + s.stop(); +}); + +test("delegate_task rejects unknown backends", async () => { + const s = startServer(); + await s.rpc(1, "initialize", {}); + 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/); + s.stop(); +}); + +test("notifications/cancelled terminates an in-flight worker", async () => { + const slowCfg = path.join(tmpdir(), "bridge-slow-backends.json"); + writeFileSync(slowCfg, JSON.stringify({ + backends: { + slow: { command: "node", buildArgs: ["-e", "setTimeout(()=>{},120000)", ""], experimental: true }, + }, + })); + const s = startServer({ CLI_AGENT_BRIDGE_BACKENDS: slowCfg }); + await s.rpc(1, "initialize", {}); + 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 } }); + 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 120s backend timeout"); + s.stop(); +}); + +test("delegate_task returns before and after snapshots and committed deltas", async () => { + const fakeCfg = path.join(tmpdir(), "bridge-fake-backends.json"); + writeFileSync(fakeCfg, JSON.stringify({ + backends: { + fake: { command: "node", buildArgs: ["-e", "require('node:fs').appendFileSync('marker.txt','ok')", ""], experimental: true }, + }, + })); + const s = startServer({ CLI_AGENT_BRIDGE_BACKENDS: fakeCfg }); + await s.rpc(1, "initialize", {}); + 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")); + s.stop(); +}); + From a42a84dba24874942806d794ef645652feb95dae Mon Sep 17 00:00:00 2001 From: Hylouis233 Date: Sun, 16 Aug 2026 01:31:54 +0800 Subject: [PATCH 07/40] Key locks by worktree root, harden cancellation and process-tree kills - workspace lock is now keyed by the canonical realpath of the git worktree root (git rev-parse --show-toplevel + fs.realpath), so the same checkout reached via a subdirectory, casing, or symlink shares one mutex (P1) - a delegation cancelled while queued for the lock re-checks the cancel flag after acquiring it and returns before spawning the worker (P1) - timeout and cancellation now terminate the whole process tree: taskkill /PID /T /F on Windows, signal to the detached process group on POSIX, instead of only killing the top-level child (P1) - git snapshots tolerate an unborn HEAD (fresh git init) and committedDelta reports the first commits when the worker started from no commits (P2) - codex templates delimit the prompt with -- so option-like tasks are not parsed as CLI flags (P2) - README comparison example now requires two independent git worktrees; the queued same-checkout second run is documented as follow-up work (P2) - SKILL resume guidance no longer implies results carry backend session ids Tests extended to 11 (protocol, tools, snapshots, dirty guard, unknown backend, in-flight cancel, before/after, worktree-root lock serialization, cancel-while-queued, unborn HEAD, codex -- delimiter); 3 consecutive runs green locally --- plugins/Hylouis233/cli-agent-bridge/README.md | 12 ++- .../Hylouis233/cli-agent-bridge/backends.json | 2 +- .../Hylouis233/cli-agent-bridge/server.mjs | 91 ++++++++++++++++--- .../skills/cli-agent-bridge/SKILL.md | 5 +- .../cli-agent-bridge/test/server.test.mjs | 81 ++++++++++++++++- 5 files changed, 170 insertions(+), 21 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 75086eb..da84670 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -35,11 +35,15 @@ 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. +then compare the two diffs. Create two independent worktrees first. ``` -Expected result: two delegate_task runs (backend=claude and backend=kimi) against the same -workspace, followed by a comparison of the two diffs reported to the user. +Expected result: the orchestrator creates two git worktrees (`git worktree add ../ws-claude`, +`git worktree add ../ws-kimi`), 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. Independent +comparison runs need separate worktrees: the first run leaves its checkout dirty, so a +same-workspace second run would be rejected by the allowDirty=false guard (same-checkout runs +are serialized into a queue, which suits follow-up work, not parallel comparisons). ## Requirements @@ -54,7 +58,7 @@ workspace, followed by a comparison of the two diffs reported to the user. | 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 | +| 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 diff --git a/plugins/Hylouis233/cli-agent-bridge/backends.json b/plugins/Hylouis233/cli-agent-bridge/backends.json index 5836f86..a4877cf 100644 --- a/plugins/Hylouis233/cli-agent-bridge/backends.json +++ b/plugins/Hylouis233/cli-agent-bridge/backends.json @@ -2,7 +2,7 @@ "$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 }, + "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/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index a4c89e3..7260c93 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -7,7 +7,7 @@ // License: MIT. See NOTICE for upstream credits. import { spawn } from "node:child_process"; -import { readFile, stat } from "node:fs/promises"; +import { readFile, stat, realpath } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import path from "node:path"; @@ -37,8 +37,10 @@ const FALLBACK_BACKENDS = { codex: { label: "OpenAI Codex CLI", command: "codex", - buildArgs: ["exec", ""], - resumeArgs: ["exec", "resume", "", ""], + // "--" delimits the prompt from CLI options so a task like "--help" cannot + // be interpreted as a codex flag. + buildArgs: ["exec", "--", ""], + resumeArgs: ["exec", "resume", "", "--", ""], experimental: false, }, kimi: { @@ -189,6 +191,27 @@ function capture() { }; } +// Kill the worker's whole process tree, not just the top-level child. On +// Windows, child.kill() ends only the .cmd/.ps1 shim while the real CLI keeps +// running, so taskkill /T /F is required; on POSIX the worker is spawned +// detached in its own process group and the group is signaled here. +function treeKill(child, signal) { + if (!child) return; + if (!child.pid) { + try { child.kill(signal); } catch { /* already gone */ } + return; + } + try { + if (process.platform === "win32") { + spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); + } else { + process.kill(-child.pid, signal); // negative pid = the whole group + } + } catch { + try { child.kill(signal); } catch { /* already gone */ } + } +} + async function runCommand(command, args, options = {}) { const spawnOnce = (argv, shellArgs) => new Promise((resolve) => { const child = shellArgs @@ -196,12 +219,14 @@ async function runCommand(command, args, options = {}) { cwd: options.cwd, env: process.env, windowsHide: true, + detached: process.platform !== "win32", // own process group for treeKill stdio: ["ignore", "pipe", "pipe"], }) : spawn(command, argv, { cwd: options.cwd, env: process.env, windowsHide: true, + detached: process.platform !== "win32", // own process group for treeKill stdio: ["ignore", "pipe", "pipe"], }); if (typeof options.onChild === "function") options.onChild(child); @@ -229,14 +254,14 @@ async function runCommand(command, args, options = {}) { }; const timer = setTimeout(() => { timedOut = true; - child.kill("SIGTERM"); + treeKill(child, "SIGTERM"); }, timeoutMs); // If the child ignores SIGTERM or a descendant holds the pipes, close never // fires; force-settle after the grace period so the MCP call cannot hang. const forceTimer = setTimeout(() => { if (settled) return; killed = true; - try { child.kill("SIGKILL"); } catch { /* already gone */ } + treeKill(child, "SIGKILL"); settle(); }, timeoutMs + KILL_GRACE_MS); @@ -346,10 +371,15 @@ async function gitSnapshot(workspacePath) { const failures = []; const out = {}; results.forEach((result, i) => { + // An unborn HEAD (fresh `git init`, no commits yet) is a valid repository + // state, not an unreliable snapshot; record it as an empty head. + if (jobs[i][1] === "head") return; const failure = snapshotFailure(jobs[i][0], result); if (failure) { failures.push(failure); return; } out[jobs[i][1]] = result.stdout; }); + const headResult = results[jobs.findIndex((j) => j[1] === "head")]; + out.head = headResult && headResult.exitCode === 0 ? String(headResult.stdout).trim() : ""; if (failures.length > 0) { // Fail closed: an unreliable snapshot must never authorize a delegation. throw new Error("git snapshot unreliable: " + failures.join("; ")); @@ -373,13 +403,18 @@ async function gitSnapshot(workspacePath) { } async function committedDelta(workspacePath, beforeHead, afterHead) { - if (!beforeHead || beforeHead === afterHead) return null; + if (!afterHead || beforeHead === afterHead) return null; + const range = beforeHead ? beforeHead + ".." + afterHead : null; const [log, stat] = await Promise.all([ - runCommand("git", ["log", "--oneline", beforeHead + ".." + afterHead], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }), - runCommand("git", ["diff", "--stat", beforeHead + ".." + afterHead], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }), + range + ? runCommand("git", ["log", "--oneline", range], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }) + : runCommand("git", ["log", "--oneline", "--max-count=50"], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }), + range + ? runCommand("git", ["diff", "--stat", range], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }) + : runCommand("git", ["show", "--stat", "--oneline", afterHead], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }), ]); return { - range: beforeHead + ".." + afterHead, + range: range ?? "(repository had no commits before the worker ran)", log: String(log.stdout ?? "").trim(), diffStat: String(stat.stdout ?? "").trim(), }; @@ -424,6 +459,23 @@ async function withWorkspaceLock(key, fn) { } } +// Lock key = canonical real path of the git worktree root. The same checkout +// reached through a subdirectory, different casing, or a symlink must share +// one mutex, otherwise two write workers could run on it concurrently. +async function worktreeKey(workspacePath) { + const topLevel = await runCommand("git", ["rev-parse", "--show-toplevel"], { + cwd: workspacePath, timeoutMs: 15_000, + }); + const root = topLevel.exitCode === 0 && topLevel.stdout.trim() + ? topLevel.stdout.trim().replace(/[\\/]+$/, "") + : workspacePath; + try { + return await realpath(root); + } catch { + return path.resolve(root); + } +} + async function delegateTask(rawArgs, cancel) { const backends = await loadBackends(); if (!rawArgs || typeof rawArgs.backend !== "string" || !rawArgs.backend.trim()) { @@ -440,7 +492,20 @@ async function delegateTask(rawArgs, cancel) { const workspacePath = await validateWorkspace(rawArgs.workspacePath); await requireGitRepo(workspacePath); - return await withWorkspaceLock("ws:" + workspacePath, async () => { + const lockKey = "wt:" + (await worktreeKey(workspacePath)); + return await withWorkspaceLock(lockKey, async () => { + // A cancellation that arrived while this request was queued behind the + // lock must not start the worker at all; re-check before spawning. + if (cancel && cancel.cancelled) { + return { + ok: false, + error: "delegation cancelled by client while waiting for the workspace lock; the worker never started", + backend, workspacePath, exitCode: null, timedOut: false, killed: false, cancelled: true, + outputTail: "", stderrTail: "", + gitBefore: null, git: null, commits: null, + experimental: Boolean(spec.experimental), + }; + } const allowDirty = rawArgs.allowDirty === true; const before = await gitSnapshot(workspacePath); if (!allowDirty && before.statusShort) { @@ -552,10 +617,8 @@ async function handleMessage(message) { if (entry && entry.cancel) { entry.cancel.cancelled = true; if (entry.cancel.child) { - try { entry.cancel.child.kill("SIGTERM"); } catch { /* already gone */ } - setTimeout(() => { - try { entry.cancel.child.kill("SIGKILL"); } catch { /* already gone */ } - }, KILL_GRACE_MS).unref?.(); + treeKill(entry.cancel.child, "SIGTERM"); + setTimeout(() => treeKill(entry.cancel.child, "SIGKILL"), KILL_GRACE_MS).unref?.(); } } return null; 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 index 0f2103c..63af0ee 100644 --- a/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -27,7 +27,10 @@ inside the target git repository, and their results come back as a git diff for files including staged and new files), 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. -5. If the result is wrong, delegate a follow-up task with resumeSessionId where supported. +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 diff --git a/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs index 2f25a7e..fd749d2 100644 --- a/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs @@ -5,7 +5,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync, mkdirSync, readFileSync } from "node:fs"; import { execSync } from "node:child_process"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -148,3 +148,82 @@ test("delegate_task returns before and after snapshots and committed deltas", as s.stop(); }); +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 cfg = path.join(tmpdir(), "bridge-order-backends.json"); + writeFileSync(cfg, JSON.stringify({ + backends: { orderer: { command: "node", buildArgs: ["-e", script, ""], experimental: true } }, + })); + const s = startServer({ CLI_AGENT_BRIDGE_BACKENDS: cfg }); + await s.rpc(1, "initialize", {}); + const first = s.rpc(2, "tools/call", { name: "delegate_task", arguments: { backend: "orderer", task: "x", workspacePath: repo, timeoutMs: 60_000 } }); + // The queued follow-up must accept the tree the first run left 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(",")); + s.stop(); +}); + +test("a delegation cancelled while queued for the lock never starts its worker", async () => { + const cfg = path.join(tmpdir(), "bridge-queue-cancel.json"); + writeFileSync(cfg, JSON.stringify({ + backends: { slow: { command: "node", buildArgs: ["-e", "setTimeout(()=>{},4000)", ""], experimental: true } }, + })); + const s = startServer({ CLI_AGENT_BRIDGE_BACKENDS: cfg }); + await s.rpc(1, "initialize", {}); + 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 new Promise((r) => setTimeout(r, 600)); + const second = s.rpc(11, "tools/call", { name: "delegate_task", arguments: { backend: "slow", task: "queued", workspacePath: repo, timeoutMs: 60_000 } }); + await new Promise((r) => setTimeout(r, 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, /waiting for the workspace lock/); + 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"); + s.stop(); +}); + +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 cfg = path.join(tmpdir(), "bridge-unborn-backends.json"); + writeFileSync(cfg, JSON.stringify({ + backends: { fake: { command: "node", buildArgs: ["-e", "require('node:fs').writeFileSync('first.txt','ok')", ""], experimental: true } }, + })); + const s = startServer({ CLI_AGENT_BRIDGE_BACKENDS: cfg }); + await s.rpc(1, "initialize", {}); + 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")); + s.stop(); +}); + +test("codex templates delimit the prompt from CLI options with --", async () => { + 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", "", "--", ""]); +}); + From d27f2e603519b90e77a796de7e8b10f747ba515a Mon Sep 17 00:00:00 2001 From: Hylouis233 Date: Sun, 16 Aug 2026 02:04:23 +0800 Subject: [PATCH 08/40] Run git snapshot commands serially; make tests leak-proof - gitSnapshot/committedDelta executed their git commands via Promise.all; git status and git diff both refresh the index, so concurrent processes raced for .git/index.lock and intermittently failed with exit code 128, surfacing as random 'git snapshot unreliable' errors (reproduced 1-in-15 on Windows; the fail-closed guard turned it into a JSON-RPC error) - commands now run serially per repository; 10/10 Windows runs and a full npm run check in a Linux node:22 container pass - test harness: every server now stops in a finally block (a leaked server kept stdin/stdout pipes open and hung node --test when an assertion failed - the cause of the stalled CI validate job), in-flight cancel test bounds its worker with an explicit timeoutMs --- .../Hylouis233/cli-agent-bridge/server.mjs | 23 +- .../cli-agent-bridge/test/server.test.mjs | 249 +++++++++--------- 2 files changed, 142 insertions(+), 130 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 7260c93..363d4fc 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -367,7 +367,13 @@ async function gitSnapshot(workspacePath) { ["git ls-files --others --exclude-standard", "untracked", ["ls-files", "--others", "--exclude-standard"]], ["git rev-parse HEAD", "head", ["rev-parse", "HEAD"]], ]; - const results = await Promise.all(jobs.map((j) => runCommand("git", j[2], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }))); + // Serial execution on purpose: git status/diff refresh the index, and + // concurrent git processes on one repository race for .git/index.lock, + // which intermittently fails commands with exit code 128. + const results = []; + for (const job of jobs) { + results.push(await runCommand("git", job[2], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS })); + } const failures = []; const out = {}; results.forEach((result, i) => { @@ -405,14 +411,13 @@ async function gitSnapshot(workspacePath) { async function committedDelta(workspacePath, beforeHead, afterHead) { if (!afterHead || beforeHead === afterHead) return null; const range = beforeHead ? beforeHead + ".." + afterHead : null; - const [log, stat] = await Promise.all([ - range - ? runCommand("git", ["log", "--oneline", range], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }) - : runCommand("git", ["log", "--oneline", "--max-count=50"], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }), - range - ? runCommand("git", ["diff", "--stat", range], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }) - : runCommand("git", ["show", "--stat", "--oneline", afterHead], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }), - ]); + // Serial like gitSnapshot: no index.lock races between git commands. + const log = range + ? await runCommand("git", ["log", "--oneline", range], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }) + : await runCommand("git", ["log", "--oneline", "--max-count=50"], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }); + const stat = range + ? await runCommand("git", ["diff", "--stat", range], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }) + : await runCommand("git", ["show", "--stat", "--oneline", afterHead], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }); return { range: range ?? "(repository had no commits before the worker ran)", log: String(log.stdout ?? "").trim(), diff --git a/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs index fd749d2..c18c5f3 100644 --- a/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs @@ -1,12 +1,15 @@ // 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 } from "node:child_process"; +import { spawn, execSync } from "node:child_process"; import { mkdtempSync, writeFileSync, mkdirSync, readFileSync } from "node:fs"; -import { execSync } from "node:child_process"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -36,6 +39,8 @@ function startServer(extraEnv = {}) { } } }); + 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"); @@ -43,10 +48,20 @@ function startServer(extraEnv = {}) { const notify = (method, params) => { child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n"); }; - const stop = () => child.kill(); + 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 }); @@ -57,6 +72,14 @@ function makeRepo() { 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" }); @@ -66,86 +89,74 @@ test("initialize negotiates only the supported protocol version", async () => { }); test("tools/list exposes the three bridge tools", async () => { - const s = startServer(); - await s.rpc(1, "initialize", {}); - 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); - s.stop(); + 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); + }); }); test("workspace_status reports changed files including untracked ones", async () => { - const s = startServer(); - await s.rpc(1, "initialize", {}); - 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")); - s.stop(); + 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 s = startServer(); - await s.rpc(1, "initialize", {}); - const repo = makeRepo(); - writeFileSync(path.join(repo, "dirty.txt"), "dirty"); - const res = await s.rpc(2, "tools/call", { name: "delegate_task", arguments: { backend: "claude", task: "x", workspacePath: repo } }); - assert.equal(res.result.structuredContent.ok, false); - assert.equal(res.result.isError, true); - assert.match(res.result.structuredContent.error, /dirty/); - s.stop(); + await withServer({}, 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: "claude", 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 () => { - const s = startServer(); - await s.rpc(1, "initialize", {}); - 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/); - s.stop(); + 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 slowCfg = path.join(tmpdir(), "bridge-slow-backends.json"); - writeFileSync(slowCfg, JSON.stringify({ - backends: { - slow: { command: "node", buildArgs: ["-e", "setTimeout(()=>{},120000)", ""], experimental: true }, - }, - })); - const s = startServer({ CLI_AGENT_BRIDGE_BACKENDS: slowCfg }); - await s.rpc(1, "initialize", {}); - 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 } }); - 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 120s backend timeout"); - s.stop(); + 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 fakeCfg = path.join(tmpdir(), "bridge-fake-backends.json"); - writeFileSync(fakeCfg, JSON.stringify({ - backends: { - fake: { command: "node", buildArgs: ["-e", "require('node:fs').appendFileSync('marker.txt','ok')", ""], experimental: true }, - }, - })); - const s = startServer({ CLI_AGENT_BRIDGE_BACKENDS: fakeCfg }); - await s.rpc(1, "initialize", {}); - 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")); - s.stop(); + 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")); + }); }); test("locks are keyed by the worktree root, so subdir paths serialize with the root", async () => { @@ -159,71 +170,67 @@ test("locks are keyed by the worktree root, so subdir paths serialize with the r const script = `const fs=require('node:fs');` + `fs.appendFileSync(${JSON.stringify(logFile)},'start\\n');` + `setTimeout(()=>{fs.appendFileSync(${JSON.stringify(logFile)},'end\\n')},700);`; - const cfg = path.join(tmpdir(), "bridge-order-backends.json"); - writeFileSync(cfg, JSON.stringify({ - backends: { orderer: { command: "node", buildArgs: ["-e", script, ""], experimental: true } }, - })); - const s = startServer({ CLI_AGENT_BRIDGE_BACKENDS: cfg }); - await s.rpc(1, "initialize", {}); - const first = s.rpc(2, "tools/call", { name: "delegate_task", arguments: { backend: "orderer", task: "x", workspacePath: repo, timeoutMs: 60_000 } }); - // The queued follow-up must accept the tree the first run left 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(",")); - s.stop(); + 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 cfg = path.join(tmpdir(), "bridge-queue-cancel.json"); - writeFileSync(cfg, JSON.stringify({ - backends: { slow: { command: "node", buildArgs: ["-e", "setTimeout(()=>{},4000)", ""], experimental: true } }, - })); - const s = startServer({ CLI_AGENT_BRIDGE_BACKENDS: cfg }); - await s.rpc(1, "initialize", {}); - 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 new Promise((r) => setTimeout(r, 600)); - const second = s.rpc(11, "tools/call", { name: "delegate_task", arguments: { backend: "slow", task: "queued", workspacePath: repo, timeoutMs: 60_000 } }); - await new Promise((r) => setTimeout(r, 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, /waiting for the workspace lock/); - 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"); - s.stop(); + 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, /waiting for the workspace lock/); + 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 cfg = path.join(tmpdir(), "bridge-unborn-backends.json"); - writeFileSync(cfg, JSON.stringify({ - backends: { fake: { command: "node", buildArgs: ["-e", "require('node:fs').writeFileSync('first.txt','ok')", ""], experimental: true } }, - })); - const s = startServer({ CLI_AGENT_BRIDGE_BACKENDS: cfg }); - await s.rpc(1, "initialize", {}); - 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")); - s.stop(); + 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 --", async () => { +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", "", "--", ""]); }); - From 6ee0a0b8c9726abcecc15db5768a7f5fcfc1859f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 11:12:46 +0800 Subject: [PATCH 09/40] fix(cli-agent-bridge): harden delegation lifecycle --- plugins/Hylouis233/cli-agent-bridge/README.md | 24 +- .../cli-agent-bridge/process-tree.mjs | 126 ++++++++ .../Hylouis233/cli-agent-bridge/server.mjs | 292 +++++++++++------- .../skills/cli-agent-bridge/SKILL.md | 12 +- .../cli-agent-bridge/test/server.test.mjs | 2 +- .../cli-agent-bridge/tests/fake-backend.mjs | 45 +++ .../cli-agent-bridge/tests/server.test.mjs | 260 ++++++++++++++++ 7 files changed, 643 insertions(+), 118 deletions(-) create mode 100644 plugins/Hylouis233/cli-agent-bridge/process-tree.mjs create mode 100644 plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs create mode 100644 plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index da84670..8a6a40e 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -18,7 +18,8 @@ repository: - 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; dirty trees are refused unless allowDirty=true; cancellation kills the worker. + serialized; dirty trees are refused unless allowDirty=true; cancellation and timeout + terminate the complete worker process tree before the workspace lock is released. The Skill teaches MiniMax Code when and how to delegate, and to review the returned diff before reporting completion. @@ -103,6 +104,8 @@ underlying real executable in backends.json. 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 @@ -111,11 +114,30 @@ change the command field. resumeSessionId is honored only for backends whose res plays that role instead. - The bridge delegates tasks; it does not merge code, commit, or push. The user reviews every diff. +- Delegations targeting the same canonical Git worktree are serialized even when callers name a + subdirectory, different path casing, or symlink. Independent comparison runs still require + separate clean worktrees. +- Cancellation and timeout confirm that the delegated process tree has exited before releasing + the workspace mutex. If termination cannot be confirmed, the bridge quarantines that worktree + and refuses further delegations until the server is restarted and leftover processes are + checked. - 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. +## 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 +``` + +They cover the full MCP flow plus canonical worktree locking, queued cancellation, cancel/timeout +process-tree termination, unborn HEAD snapshots, and Codex prompt delimiters on Windows and POSIX. + ## License MIT. See LICENSE. Upstream credits: see NOTICE. 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..b943006 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs @@ -0,0 +1,126 @@ +import { spawn } from "node:child_process"; + +const UTILITY_CAPTURE_CHARS = 1_000_000; + +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) { + return new Promise((resolve) => { + const child = spawn(command, args, { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + const done = (exitCode, error = null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ exitCode, stdout, stderr, error }); + }; + 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) => { stdout = appendBounded(stdout, chunk); }); + child.stderr.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); }); + child.on("error", (error) => done(null, error)); + child.on("close", (code) => done(code)); + }); +} + +async function windowsProcessTreePids(rootPid, knownPids = new Set()) { + const script = [ + "$ErrorActionPreference='Stop'", + "$items=@(Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId)", + "$items | ConvertTo-Json -Compress", + ].join("; "); + const result = await runUtility("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), + })); + const livePids = new Set(processes.map((item) => item.pid)); + const descendants = new Set([...knownPids].filter((pid) => livePids.has(pid))); + if (livePids.has(rootPid)) descendants.add(rootPid); + const parents = new Set([rootPid, ...knownPids]); + let changed = true; + while (changed) { + changed = false; + for (const item of processes) { + if (parents.has(item.parentPid) && !descendants.has(item.pid)) { + descendants.add(item.pid); + parents.add(item.pid); + changed = true; + } + } + } + for (const pid of descendants) knownPids.add(pid); + return [...descendants]; +} + +export async function isProcessTreeAlive(child, treeState) { + if (!Number.isInteger(child.pid)) return false; + if (process.platform === "win32") { + return (await windowsProcessTreePids(child.pid, treeState.knownPids)).length > 0; + } + try { + process.kill(-child.pid, 0); + return true; + } catch (error) { + return error.code === "EPERM"; + } +} + +export async function signalProcessTree(child, signal, treeState) { + if (!Number.isInteger(child.pid)) return; + if (process.platform === "win32") { + // Windows has no portable SIGTERM equivalent for arbitrary console CLIs; + // /T /F is required to terminate the complete tree deterministically. + await runUtility("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"]); + // If the root exited first, taskkill cannot traverse it. Win32_Process + // normally retains the old parent PID, while known PIDs survive re-parenting. + const remaining = await windowsProcessTreePids(child.pid, treeState.knownPids); + for (const pid of remaining.reverse()) { + await runUtility("taskkill.exe", ["/PID", String(pid), "/T", "/F"]); + } + return; + } + try { + process.kill(-child.pid, signal); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } +} + +export async function waitForProcessTreeExit(child, timeoutMs, treeState) { + const deadline = Date.now() + timeoutMs; + while (await isProcessTreeAlive(child, treeState)) { + 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/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 363d4fc..7db4e8d 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -7,10 +7,12 @@ // License: MIT. See NOTICE for upstream credits. import { spawn } from "node:child_process"; -import { readFile, stat, realpath } from "node:fs/promises"; +import { readFile, realpath, stat } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import path from "node:path"; +import { isProcessTreeAlive, signalProcessTree, waitForChildExit, waitForProcessTreeExit } from "./process-tree.mjs"; + const SERVER_NAME = "cli-agent-bridge"; const SERVER_VERSION = "0.1.0"; const PROTOCOL_VERSION = "2025-06-18"; @@ -37,8 +39,6 @@ const FALLBACK_BACKENDS = { codex: { label: "OpenAI Codex CLI", command: "codex", - // "--" delimits the prompt from CLI options so a task like "--help" cannot - // be interpreted as a codex flag. buildArgs: ["exec", "--", ""], resumeArgs: ["exec", "resume", "", "--", ""], experimental: false, @@ -100,7 +100,7 @@ const TOOLS = [ 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. Delegations to the same workspace are serialized.", + "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.", inputSchema: { type: "object", additionalProperties: false, @@ -135,7 +135,7 @@ const TOOLS = [ minimum: MIN_TIMEOUT_MS, maximum: MAX_TIMEOUT_MS, default: DEFAULT_TIMEOUT_MS, - description: "Execution timeout in milliseconds. Defaults to 1200000 (20 minutes). After the timeout the worker receives SIGTERM, then a forceful kill after a 10 second grace period.", + description: "Execution timeout in milliseconds. Defaults to 1200000 (20 minutes). Timeout terminates the entire worker process tree; POSIX workers receive SIGTERM then SIGKILL after a 10 second grace period, while Windows uses taskkill /T /F.", }, }, required: ["backend", "task", "workspacePath"], @@ -191,79 +191,91 @@ function capture() { }; } -// Kill the worker's whole process tree, not just the top-level child. On -// Windows, child.kill() ends only the .cmd/.ps1 shim while the real CLI keeps -// running, so taskkill /T /F is required; on POSIX the worker is spawned -// detached in its own process group and the group is signaled here. -function treeKill(child, signal) { - if (!child) return; - if (!child.pid) { - try { child.kill(signal); } catch { /* already gone */ } - return; - } - try { - if (process.platform === "win32") { - spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); - } else { - process.kill(-child.pid, signal); // negative pid = the whole group - } - } catch { - try { child.kill(signal); } catch { /* already gone */ } - } -} - async function runCommand(command, args, options = {}) { const spawnOnce = (argv, shellArgs) => new Promise((resolve) => { + const manageProcessTree = options.manageProcessTree === true; const child = shellArgs ? spawn(shellArgs[0], shellArgs.slice(1), { cwd: options.cwd, env: process.env, + detached: manageProcessTree && process.platform !== "win32", windowsHide: true, - detached: process.platform !== "win32", // own process group for treeKill - stdio: ["ignore", "pipe", "pipe"], + stdio: [options.stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"], }) : spawn(command, argv, { cwd: options.cwd, env: process.env, + detached: manageProcessTree && process.platform !== "win32", windowsHide: true, - detached: process.platform !== "win32", // own process group for treeKill - stdio: ["ignore", "pipe", "pipe"], + stdio: [options.stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"], }); - if (typeof options.onChild === "function") options.onChild(child); const stdoutBuf = capture(); const stderrBuf = capture(); let settled = false; let spawnError = null; let timedOut = false; let killed = false; + let orphanedProcesses = false; + let treeTerminated = true; + let terminationError = ""; + let exitCode = null; + let terminationPromise = null; + const treeState = { knownPids: new Set(Number.isInteger(child.pid) ? [child.pid] : []) }; const timeoutMs = options.timeoutMs ?? 30_000; const settle = () => { if (settled) return; settled = true; clearTimeout(timer); - clearTimeout(forceTimer); resolve({ stdout: stdoutBuf.text(), stderr: stderrBuf.text(), - exitCode: null, + exitCode, timedOut, killed, + orphanedProcesses, + treeTerminated, + terminationError, errorMessage: "", spawnError, }); }; - const timer = setTimeout(() => { - timedOut = true; - treeKill(child, "SIGTERM"); - }, timeoutMs); - // If the child ignores SIGTERM or a descendant holds the pipes, close never - // fires; force-settle after the grace period so the MCP call cannot hang. - const forceTimer = setTimeout(() => { - if (settled) return; - killed = true; - treeKill(child, "SIGKILL"); - settle(); - }, timeoutMs + KILL_GRACE_MS); + + const terminate = (reason) => { + if (reason === "timeout") timedOut = true; + if (reason === "orphaned") orphanedProcesses = true; + if (terminationPromise) return terminationPromise; + terminationPromise = (async () => { + if (manageProcessTree) await signalProcessTree(child, "SIGTERM", treeState); + else try { child.kill("SIGTERM"); } catch { /* already gone */ } + const exited = manageProcessTree + ? await waitForProcessTreeExit(child, KILL_GRACE_MS, treeState) + : await waitForChildExit(child, KILL_GRACE_MS); + if (!exited) { + killed = true; + if (manageProcessTree) await signalProcessTree(child, "SIGKILL", treeState); + else try { child.kill("SIGKILL"); } catch { /* already gone */ } + treeTerminated = manageProcessTree + ? await waitForProcessTreeExit(child, KILL_GRACE_MS, treeState) + : await waitForChildExit(child, KILL_GRACE_MS); + if (!treeTerminated) { + terminationError = "process tree still appears alive after forceful termination"; + } + } + settle(); + })().catch((error) => { + treeTerminated = false; + terminationError = error.message; + settle(); + }); + return terminationPromise; + }; + + if (typeof options.onChild === "function" && Number.isInteger(child.pid)) { + options.onChild({ child, terminate }); + } + if (child.stdin) child.stdin.end(options.stdinText); + + const timer = setTimeout(() => { void terminate("timeout"); }, timeoutMs); child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); @@ -275,36 +287,34 @@ async function runCommand(command, args, options = {}) { if (settled) return; settled = true; clearTimeout(timer); - clearTimeout(forceTimer); resolve({ stdout: stdoutBuf.text(), stderr: stderrBuf.text(), exitCode: null, timedOut, killed, + orphanedProcesses, + treeTerminated, + terminationError, errorMessage: error.message, spawnError, }); }); - child.on("close", (code) => { + child.on("close", async (code) => { if (settled) return; - settled = true; - clearTimeout(timer); - clearTimeout(forceTimer); - resolve({ - stdout: stdoutBuf.text(), - stderr: stderrBuf.text(), - exitCode: code, - timedOut, - killed, - errorMessage: "", - spawnError, - }); + exitCode = code; + if (terminationPromise) return; + if (manageProcessTree && await isProcessTreeAlive(child, treeState)) { + await terminate("orphaned"); + return; + } + settle(); }); }); const direct = await spawnOnce(args, null); if (process.platform !== "win32" || !direct.spawnError) 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 @@ -351,6 +361,26 @@ async function requireGitRepo(workspacePath) { } } +async function gitWorktreeRoot(workspacePath) { + const result = await runCommand("git", ["rev-parse", "--show-toplevel"], { + cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS, + }); + const failure = snapshotFailure("git rev-parse --show-toplevel", result); + if (failure || !result.stdout.trim()) { + throw new Error("cannot identify Git worktree root: " + (failure || "empty output")); + } + try { + return await realpath(result.stdout.trim()); + } catch (error) { + throw new Error("cannot canonicalize Git worktree root: " + error.message); + } +} + +function workspaceLockKey(worktreeRoot) { + const normalized = path.normalize(worktreeRoot); + return "git-worktree:" + (process.platform === "win32" ? normalized.toLowerCase() : normalized); +} + function snapshotFailure(label, result) { if (result.timedOut) return label + " timed out"; if (result.exitCode !== 0) return label + " failed with exit code " + String(result.exitCode); @@ -365,11 +395,10 @@ async function gitSnapshot(workspacePath) { ["git diff --cached --stat", "cachedDiffStat", ["diff", "--cached", "--stat"]], ["git diff --cached --name-only", "cachedDiffNames", ["diff", "--cached", "--name-only"]], ["git ls-files --others --exclude-standard", "untracked", ["ls-files", "--others", "--exclude-standard"]], - ["git rev-parse HEAD", "head", ["rev-parse", "HEAD"]], + ["git rev-parse --verify --quiet HEAD", "head", ["rev-parse", "--verify", "--quiet", "HEAD"], true], ]; - // Serial execution on purpose: git status/diff refresh the index, and - // concurrent git processes on one repository race for .git/index.lock, - // which intermittently fails commands with exit code 128. + // Run serially: status/diff may both refresh the index, so concurrent Git + // processes can race for .git/index.lock on the same repository. const results = []; for (const job of jobs) { results.push(await runCommand("git", job[2], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS })); @@ -377,15 +406,14 @@ async function gitSnapshot(workspacePath) { const failures = []; const out = {}; results.forEach((result, i) => { - // An unborn HEAD (fresh `git init`, no commits yet) is a valid repository - // state, not an unreliable snapshot; record it as an empty head. - if (jobs[i][1] === "head") return; + if (jobs[i][3] === true && result.exitCode === 1 && !result.timedOut) { + out[jobs[i][1]] = ""; + return; + } const failure = snapshotFailure(jobs[i][0], result); if (failure) { failures.push(failure); return; } out[jobs[i][1]] = result.stdout; }); - const headResult = results[jobs.findIndex((j) => j[1] === "head")]; - out.head = headResult && headResult.exitCode === 0 ? String(headResult.stdout).trim() : ""; if (failures.length > 0) { // Fail closed: an unreliable snapshot must never authorize a delegation. throw new Error("git snapshot unreliable: " + failures.join("; ")); @@ -410,16 +438,31 @@ async function gitSnapshot(workspacePath) { async function committedDelta(workspacePath, beforeHead, afterHead) { if (!afterHead || beforeHead === afterHead) return null; - const range = beforeHead ? beforeHead + ".." + afterHead : null; - // Serial like gitSnapshot: no index.lock races between git commands. - const log = range - ? await runCommand("git", ["log", "--oneline", range], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }) - : await runCommand("git", ["log", "--oneline", "--max-count=50"], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }); - const stat = range - ? await runCommand("git", ["diff", "--stat", range], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }) - : await runCommand("git", ["show", "--stat", "--oneline", afterHead], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS }); + let range; + if (beforeHead) { + range = beforeHead + ".." + afterHead; + } else { + const emptyTree = await runCommand("git", ["mktree"], { + cwd: workspacePath, + timeoutMs: GIT_TIMEOUT_MS, + stdinText: "", + }); + 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")); + } + range = emptyTree.stdout.trim() + ".." + afterHead; + } + const log = await runCommand("git", ["log", "--oneline", beforeHead ? range : afterHead], { + cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS, + }); + const stat = await runCommand("git", ["diff", "--stat", range], { + cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS, + }); + const failures = [snapshotFailure("git log", log), snapshotFailure("git diff --stat", stat)].filter(Boolean); + if (failures.length > 0) throw new Error("committed delta unreliable: " + failures.join("; ")); return { - range: range ?? "(repository had no commits before the worker ran)", + range, log: String(log.stdout ?? "").trim(), diffStat: String(stat.stdout ?? "").trim(), }; @@ -449,6 +492,7 @@ async function listBackends() { // Per-workspace mutex: concurrent delegations to the same checkout are // serialized so workers cannot interleave edits or snapshot each other. const workspaceLocks = new Map(); +const quarantinedWorkspaces = new Set(); async function withWorkspaceLock(key, fn) { const prev = workspaceLocks.get(key) ?? Promise.resolve(); let release; @@ -464,21 +508,25 @@ async function withWorkspaceLock(key, fn) { } } -// Lock key = canonical real path of the git worktree root. The same checkout -// reached through a subdirectory, different casing, or a symlink must share -// one mutex, otherwise two write workers could run on it concurrently. -async function worktreeKey(workspacePath) { - const topLevel = await runCommand("git", ["rev-parse", "--show-toplevel"], { - cwd: workspacePath, timeoutMs: 15_000, - }); - const root = topLevel.exitCode === 0 && topLevel.stdout.trim() - ? topLevel.stdout.trim().replace(/[\\/]+$/, "") - : workspacePath; - try { - return await realpath(root); - } catch { - return path.resolve(root); - } +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), + }; } async function delegateTask(rawArgs, cancel) { @@ -496,28 +544,34 @@ async function delegateTask(rawArgs, cancel) { } const workspacePath = await validateWorkspace(rawArgs.workspacePath); await requireGitRepo(workspacePath); + const worktreeRoot = await gitWorktreeRoot(workspacePath); + const lockKey = workspaceLockKey(worktreeRoot); - const lockKey = "wt:" + (await worktreeKey(workspacePath)); return await withWorkspaceLock(lockKey, async () => { - // A cancellation that arrived while this request was queued behind the - // lock must not start the worker at all; re-check before spawning. - if (cancel && cancel.cancelled) { + if (quarantinedWorkspaces.has(lockKey)) { return { ok: false, - error: "delegation cancelled by client while waiting for the workspace lock; the worker never started", - backend, workspacePath, exitCode: null, timedOut: false, killed: false, cancelled: true, - outputTail: "", stderrTail: "", + error: "this workspace is quarantined because a previous worker process tree could not be confirmed terminated; restart the bridge after checking for leftover processes", + backend, workspacePath, worktreeRoot, exitCode: null, timedOut: false, killed: false, cancelled: false, + treeTerminated: false, outputTail: "", stderrTail: "", gitBefore: null, git: null, commits: null, experimental: Boolean(spec.experimental), }; } + if (cancel && cancel.cancelled) { + return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); + } const allowDirty = rawArgs.allowDirty === true; const before = await gitSnapshot(workspacePath); + 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, exitCode: null, timedOut: false, killed: false, cancelled: false, + 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), @@ -533,7 +587,8 @@ async function delegateTask(rawArgs, cancel) { return { ok: false, error: "backend \"" + backend + "\" has no command template configured", - backend, workspacePath, exitCode: null, timedOut: false, killed: false, cancelled: false, + 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), @@ -545,18 +600,31 @@ async function delegateTask(rawArgs, cancel) { : DEFAULT_TIMEOUT_MS; 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 }); + } + const result = await runCommand(spec.command, args, { cwd: workspacePath, timeoutMs, - onChild: (child) => { if (cancel) cancel.child = child; }, + manageProcessTree: true, + shouldCancel: () => Boolean(cancel && cancel.cancelled), + onChild: (controller) => { if (cancel) cancel.controller = controller; }, }); - const after = await gitSnapshot(workspacePath); - const commits = await committedDelta(workspacePath, before.head, after.head); + if (!result.treeTerminated) quarantinedWorkspaces.add(lockKey); + const after = result.treeTerminated ? await gitSnapshot(workspacePath) : before; + const commits = result.treeTerminated ? await committedDelta(workspacePath, before.head, after.head) : null; let error = ""; - if (cancel && cancel.cancelled) { + if (!result.treeTerminated) { + error = "backend process tree could not be confirmed terminated; the workspace is quarantined until the bridge restarts"; + } else if (cancel && cancel.cancelled) { error = "delegation cancelled by client"; } else if (result.timedOut) { error = "backend \"" + backend + "\" timed out after " + timeoutMs + " ms" + (result.killed ? " and was force-killed" : ""); + } else if (result.orphanedProcesses) { + error = "backend exited while descendant processes were still running; the bridge terminated the remaining process tree"; } else if (result.exitCode !== 0) { error = "backend \"" + backend + "\" exited with code " + String(result.exitCode); } @@ -566,10 +634,14 @@ async function delegateTask(rawArgs, cancel) { error, backend, workspacePath, + worktreeRoot, exitCode: result.exitCode, timedOut: result.timedOut, killed: result.killed, cancelled: Boolean(cancel && cancel.cancelled), + orphanedProcesses: result.orphanedProcesses, + treeTerminated: result.treeTerminated, + terminationError: result.terminationError, outputTail: tail(result.stdout, RAW_TAIL_CHARS), stderrTail: tail(result.stderr, RAW_TAIL_CHARS), gitBefore: before, @@ -591,7 +663,7 @@ function textResult(header, obj) { 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 || "(unknown)", "~~~"); + lines.push("", "## " + label + " HEAD", "", "~~~text", git.head || "(unborn)", "~~~"); }; gitBlock("before", obj.gitBefore); gitBlock("after", obj.git); @@ -621,9 +693,8 @@ async function handleMessage(message) { const entry = activeRequests.get(String(requestId)); if (entry && entry.cancel) { entry.cancel.cancelled = true; - if (entry.cancel.child) { - treeKill(entry.cancel.child, "SIGTERM"); - setTimeout(() => treeKill(entry.cancel.child, "SIGKILL"), KILL_GRACE_MS).unref?.(); + if (entry.cancel.controller) { + void entry.cancel.controller.terminate("cancelled"); } } return null; @@ -675,7 +746,7 @@ async function handleMessage(message) { }); } if (params.name === "delegate_task") { - const cancel = { child: null, cancelled: false }; + const cancel = { controller: null, cancelled: false }; activeRequests.set(String(message.id), { cancel }); try { const out = await delegateTask(args, cancel); @@ -728,4 +799,3 @@ function startStdioServer({ stdin = process.stdin, stdout = process.stdout } = { if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) { startStdioServer(); } - 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 index 63af0ee..09a6bfa 100644 --- a/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -48,12 +48,14 @@ inside the target git repository, and their results come back as a git diff for 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 independent or comparison workers in separate clean Git worktrees at the same starting + commit. A second run in one checkout inherits the first run's edits and is not independent. - Timeouts: the default is 20 minutes; adjust timeoutMs for very large tasks. A timed-out worker - is terminated (SIGTERM, then a forceful kill after a grace period), so a delegation call never - hangs past the cap. -- Cancellation: cancelling an in-flight delegate_task call terminates the worker process and the - result reports cancelled=true; the workspace may still contain the edits the worker made before - cancellation, so still review the returned snapshot. + has its complete process tree terminated before the workspace lock is released. +- 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 + quarantines the worktree and blocks another worker until restart. The workspace may still contain + edits made before cancellation, so still review the returned snapshot. ## Notes diff --git a/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs index c18c5f3..150059d 100644 --- a/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs @@ -205,7 +205,7 @@ test("a delegation cancelled while queued for the lock never starts its worker", 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, /waiting for the workspace lock/); + 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"); }); 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..1019fdf --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +import { appendFileSync, writeFileSync } from "node:fs"; +import { 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] ?? "{}"); + +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.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"); + 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(), + windowsHide: true, + stdio: "inherit", + }); + await new Promise(() => {}); +} 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/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs new file mode 100644 index 0000000..a3f8f8a --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -0,0 +1,260 @@ +import assert from "node:assert/strict"; +import { execFile, spawn } from "node:child_process"; +import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createInterface } from "node:readline"; +import test from "node:test"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +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"); + +class McpClient { + constructor(configPath) { + this.child = execServer(configPath); + 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(String(message.id)); + if (!entry) return; + clearTimeout(entry.timer); + this.pending.delete(String(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(String(id)); + reject(new Error("timed out waiting for request " + String(id) + ": " + this.stderr)); + }, 25_000); + this.pending.set(String(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)); + } +} + +function execServer(configPath) { + return spawn(process.execPath, [serverPath], { + env: { ...process.env, CLI_AGENT_BRIDGE_BACKENDS: configPath }, + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + }); +} + +async function makeHarness(context, { unborn = false } = {}) { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-bridge-test-")); + const workspace = path.join(tempRoot, "workspace"); + await mkdir(workspace); + 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 }); + } + 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); + await client.initialize(); + context.after(async () => { + await client.close(); + await rm(tempRoot, { recursive: true, force: true }); + }); + return { tempRoot, workspace, client }; +} + +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)); + } +} + +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("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 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: 600, + }), 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)); + client.notify("notifications/cancelled", { requestId: 102, reason: "test cancellation" }); + + const [firstResponse, queuedResponse] = await Promise.all([first, queued]); + assert.equal(firstResponse.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); + await assert.rejects(access(path.join(workspace, "must-not-exist.txt")), /ENOENT/u); +}); + +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); + 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("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"]); +}); From 0c7a4019326a3664762a5a2e930bb540f204e402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 11:36:47 +0800 Subject: [PATCH 10/40] fix(cli-agent-bridge): address current review gaps --- .../cli-agent-bridge/ps1-runner.ps1 | 12 +- .../Hylouis233/cli-agent-bridge/server.mjs | 39 ++++--- .../cli-agent-bridge/tests/server.test.mjs | 103 +++++++++++++++++- 3 files changed, 133 insertions(+), 21 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 b/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 index 87d6f6b..7f329ce 100644 --- a/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 +++ b/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 @@ -3,6 +3,14 @@ # signs survive verbatim. $Command = $args[0] $rest = $args | Select-Object -Skip 1 -& $Command @rest -exit $LASTEXITCODE +$ErrorActionPreference = "Stop" +$global:LASTEXITCODE = $null +try { + & $Command @rest + if ($null -eq $LASTEXITCODE) { exit 0 } + exit [int]$LASTEXITCODE +} 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 index 7db4e8d..031a176 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -193,6 +193,14 @@ function capture() { async function runCommand(command, args, options = {}) { const spawnOnce = (argv, shellArgs) => new Promise((resolve) => { + if (typeof options.shouldCancel === "function" && options.shouldCancel()) { + resolve({ + stdout: "", stderr: "", exitCode: null, timedOut: false, killed: false, + orphanedProcesses: false, treeTerminated: true, terminationError: "", + errorMessage: "command cancelled before spawn", spawnError: null, + }); + return; + } const manageProcessTree = options.manageProcessTree === true; const child = shellArgs ? spawn(shellArgs[0], shellArgs.slice(1), { @@ -391,10 +399,10 @@ async function gitSnapshot(workspacePath) { const jobs = [ ["git status --short", "status", ["status", "--short"]], ["git diff --stat", "diffStat", ["diff", "--stat"]], - ["git diff --name-only", "diffNames", ["diff", "--name-only"]], + ["git diff --name-only -z", "diffNames", ["diff", "--name-only", "-z"]], ["git diff --cached --stat", "cachedDiffStat", ["diff", "--cached", "--stat"]], - ["git diff --cached --name-only", "cachedDiffNames", ["diff", "--cached", "--name-only"]], - ["git ls-files --others --exclude-standard", "untracked", ["ls-files", "--others", "--exclude-standard"]], + ["git diff --cached --name-only -z", "cachedDiffNames", ["diff", "--cached", "--name-only", "-z"]], + ["git ls-files --others --exclude-standard -z", "untracked", ["ls-files", "--others", "--exclude-standard", "-z"]], ["git rev-parse --verify --quiet HEAD", "head", ["rev-parse", "--verify", "--quiet", "HEAD"], true], ]; // Run serially: status/diff may both refresh the index, so concurrent Git @@ -419,10 +427,11 @@ async function gitSnapshot(workspacePath) { throw new Error("git snapshot unreliable: " + failures.join("; ")); } const seen = new Set(); + const nulNames = (value) => String(value ?? "").split("\0").filter((name) => name.length > 0); const changedFiles = [ - ...String(out.diffNames ?? "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean), - ...String(out.cachedDiffNames ?? "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean), - ...String(out.untracked ?? "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean), + ...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(), String(out.cachedDiffStat ?? "").trim()] .filter(Boolean) @@ -562,7 +571,7 @@ async function delegateTask(rawArgs, cancel) { return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); } const allowDirty = rawArgs.allowDirty === true; - const before = await gitSnapshot(workspacePath); + const before = await gitSnapshot(worktreeRoot); if (cancel && cancel.cancelled) { return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec, before }); } @@ -614,8 +623,8 @@ async function delegateTask(rawArgs, cancel) { onChild: (controller) => { if (cancel) cancel.controller = controller; }, }); if (!result.treeTerminated) quarantinedWorkspaces.add(lockKey); - const after = result.treeTerminated ? await gitSnapshot(workspacePath) : before; - const commits = result.treeTerminated ? await committedDelta(workspacePath, before.head, after.head) : null; + const after = result.treeTerminated ? await gitSnapshot(worktreeRoot) : before; + const commits = result.treeTerminated ? await committedDelta(worktreeRoot, before.head, after.head) : null; let error = ""; if (!result.treeTerminated) { error = "backend process tree could not be confirmed terminated; the workspace is quarantined until the bridge restarts"; @@ -690,7 +699,7 @@ async function handleMessage(message) { } if (message.method === "notifications/cancelled") { const requestId = message.params?.requestId ?? message.params?.id; - const entry = activeRequests.get(String(requestId)); + const entry = activeRequests.get(requestId); if (entry && entry.cancel) { entry.cancel.cancelled = true; if (entry.cancel.controller) { @@ -739,15 +748,17 @@ async function handleMessage(message) { if (params.name === "workspace_status") { const workspacePath = await validateWorkspace(args.workspacePath); await requireGitRepo(workspacePath); - const git = await gitSnapshot(workspacePath); + const worktreeRoot = await gitWorktreeRoot(workspacePath); + const git = await withWorkspaceLock(workspaceLockKey(worktreeRoot), () => gitSnapshot(worktreeRoot)); return jsonRpcResult(message.id, { content: [{ type: "text", text: textResult("Workspace Status", { workspacePath, git }) }], - structuredContent: { ok: true, workspacePath, git }, + structuredContent: { ok: true, workspacePath, worktreeRoot, git }, }); } if (params.name === "delegate_task") { const cancel = { controller: null, cancelled: false }; - activeRequests.set(String(message.id), { cancel }); + const entry = { cancel }; + activeRequests.set(message.id, entry); try { const out = await delegateTask(args, cancel); return jsonRpcResult(message.id, { @@ -756,7 +767,7 @@ async function handleMessage(message) { isError: !out.ok, }); } finally { - activeRequests.delete(String(message.id)); + if (activeRequests.get(message.id) === entry) activeRequests.delete(message.id); } } return jsonRpcError(message.id, -32602, "Unknown tool: " + params.name); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index a3f8f8a..d9fae91 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; -import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { createInterface } from "node:readline"; @@ -25,10 +25,10 @@ class McpClient { const lines = createInterface({ input: this.child.stdout, crlfDelay: Infinity }); lines.on("line", (line) => { const message = JSON.parse(line); - const entry = this.pending.get(String(message.id)); + const entry = this.pending.get(message.id); if (!entry) return; clearTimeout(entry.timer); - this.pending.delete(String(message.id)); + this.pending.delete(message.id); entry.resolve(message); }); this.child.on("exit", (code) => { @@ -53,10 +53,10 @@ class McpClient { request(method, params, id = this.nextId++) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { - this.pending.delete(String(id)); + this.pending.delete(id); reject(new Error("timed out waiting for request " + String(id) + ": " + this.stderr)); }, 25_000); - this.pending.set(String(id), { resolve, reject, timer }); + this.pending.set(id, { resolve, reject, timer }); this.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); }); } @@ -174,6 +174,99 @@ test("canonical Git worktree locking serializes root and symlink paths", async ( assert.equal(firstResponse.result.structuredContent.worktreeRoot, secondResponse.result.structuredContent.worktreeRoot); }); +test("workspace status reports NUL-delimited root-relative paths from a subdirectory", 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 response = await client.request("tools/call", { + name: "workspace_status", + arguments: { workspacePath: nested }, + }); + const changed = response.result.structuredContent.git.changedFiles; + assert.ok(changed.includes("nested/deep/ leading.txt"), JSON.stringify(changed)); + assert.ok(changed.includes("nested/deep/café-staged.txt"), JSON.stringify(changed)); + assert.ok(changed.includes("nested/deep/café-untracked.txt"), JSON.stringify(changed)); + assert.ok(changed.includes("root-new.txt"), JSON.stringify(changed)); + assert.equal(response.result.structuredContent.worktreeRoot, await realpath(workspace)); +}); + +test("workspace status waits for an active delegation on the canonical worktree lock", 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: "writer", eventFile, delayMs: 900, writeFile: "finished.txt", + }), 311); + await waitFor(async () => (await events(eventFile)).some((item) => item.event === "start")); + + const status = client.request("tools/call", { + name: "workspace_status", + arguments: { workspacePath: nested }, + }, 312); + const early = await Promise.race([ + status.then(() => "completed"), + new Promise((resolve) => setTimeout(() => resolve("pending"), 250)), + ]); + assert.equal(early, "pending", "status must wait while the delegation owns the workspace 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("cancellation distinguishes numeric and string JSON-RPC request ids", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + const other = path.join(tempRoot, "workspace-other"); + 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"), "baseline\n"); + await execFileAsync("git", ["add", "baseline.txt"], { cwd: other }); + await execFileAsync("git", ["commit", "-m", "baseline"], { cwd: other }); + const eventFile = path.join(tempRoot, "id-events.jsonl"); + + const numeric = client.request("tools/call", taskArguments(workspace, { + name: "numeric", eventFile, delayMs: 1_200, writeFile: "numeric.txt", + }), 313); + const string = client.request("tools/call", taskArguments(other, { + name: "string", eventFile, delayMs: 1_200, writeFile: "string.txt", + }), "313"); + 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: 313, reason: "numeric only" }); + + const [numericResponse, stringResponse] = await Promise.all([numeric, string]); + assert.equal(numericResponse.result.structuredContent.cancelled, true); + assert.equal(stringResponse.result.structuredContent.cancelled, false); + assert.equal(stringResponse.result.structuredContent.ok, true); +}); + +test("PowerShell runner returns nonzero when the backend command is missing", { + skip: process.platform !== "win32", +}, async () => { + const runner = path.join(pluginRoot, "ps1-runner.ps1"); + await assert.rejects(execFileAsync("powershell.exe", [ + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", runner, + "cli-agent-bridge-command-that-does-not-exist-7f0d0f5b", + ]), (error) => error.code === 127); +}); + 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"); From 1612d21f5458d5e5c498efcce270c4ba502a26b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 11:41:13 +0800 Subject: [PATCH 11/40] fix(cli-agent-bridge): close remaining review gaps --- plugins/Hylouis233/cli-agent-bridge/README.md | 3 + .../cli-agent-bridge/ps1-runner.ps1 | 15 +- .../Hylouis233/cli-agent-bridge/server.mjs | 223 +++++++++++++--- .../skills/cli-agent-bridge/SKILL.md | 6 +- .../cli-agent-bridge/tests/server.test.mjs | 242 +++++++++++------- 5 files changed, 343 insertions(+), 146 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 8a6a40e..0c7781b 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -121,6 +121,9 @@ you already obtained a valid ID from that backend outside this Plugin. the workspace mutex. If termination cannot be confirmed, the bridge quarantines that worktree and refuses further delegations until the server is restarted and leftover processes are checked. +- timeoutMs is an overall deadline that starts after the workspace lock is acquired and covers + preflight Git checks, the worker, and post-run snapshots. Safe process-tree termination can + extend beyond that deadline by the documented kill grace period. - 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 diff --git a/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 b/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 index 7f329ce..93bba18 100644 --- a/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 +++ b/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 @@ -3,13 +3,20 @@ # signs survive verbatim. $Command = $args[0] $rest = $args | Select-Object -Skip 1 -$ErrorActionPreference = "Stop" -$global:LASTEXITCODE = $null +if ([string]::IsNullOrWhiteSpace($Command)) { + [Console]::Error.WriteLine("backend command is missing") + exit 127 +} + try { - & $Command @rest + $resolved = Get-Command -Name $Command -CommandType Application, ExternalScript -ErrorAction Stop + $global:LASTEXITCODE = $null + & $resolved.Source @rest + if (-not $?) { exit 1 } if ($null -eq $LASTEXITCODE) { exit 0 } exit [int]$LASTEXITCODE -} catch { +} +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 index 031a176..8422a07 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -135,7 +135,7 @@ const TOOLS = [ minimum: MIN_TIMEOUT_MS, maximum: MAX_TIMEOUT_MS, default: DEFAULT_TIMEOUT_MS, - description: "Execution timeout in milliseconds. Defaults to 1200000 (20 minutes). Timeout terminates the entire worker process tree; POSIX workers receive SIGTERM then SIGKILL after a 10 second grace period, while Windows uses taskkill /T /F.", + description: "Overall deadline in milliseconds after the workspace lock is acquired. Covers 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"], @@ -230,6 +230,7 @@ async function runCommand(command, args, options = {}) { let terminationPromise = null; const treeState = { knownPids: new Set(Number.isInteger(child.pid) ? [child.pid] : []) }; const timeoutMs = options.timeoutMs ?? 30_000; + const killGraceMs = options.killGraceMs ?? KILL_GRACE_MS; const settle = () => { if (settled) return; settled = true; @@ -256,15 +257,15 @@ async function runCommand(command, args, options = {}) { if (manageProcessTree) await signalProcessTree(child, "SIGTERM", treeState); else try { child.kill("SIGTERM"); } catch { /* already gone */ } const exited = manageProcessTree - ? await waitForProcessTreeExit(child, KILL_GRACE_MS, treeState) - : await waitForChildExit(child, KILL_GRACE_MS); + ? await waitForProcessTreeExit(child, killGraceMs, treeState) + : await waitForChildExit(child, killGraceMs); if (!exited) { killed = true; if (manageProcessTree) await signalProcessTree(child, "SIGKILL", treeState); else try { child.kill("SIGKILL"); } catch { /* already gone */ } treeTerminated = manageProcessTree - ? await waitForProcessTreeExit(child, KILL_GRACE_MS, treeState) - : await waitForChildExit(child, KILL_GRACE_MS); + ? await waitForProcessTreeExit(child, killGraceMs, treeState) + : await waitForChildExit(child, killGraceMs); if (!treeTerminated) { terminationError = "process tree still appears alive after forceful termination"; } @@ -395,7 +396,34 @@ function snapshotFailure(label, result) { return ""; } -async function gitSnapshot(workspacePath) { +class OperationCancelledError extends Error {} +class DeadlineExceededError extends Error {} + +async function runGitCommand(args, { cwd, cancel = null, deadline = null, stdinText } = {}) { + if (cancel?.cancelled) throw new OperationCancelledError("operation cancelled by client"); + const remaining = deadline === null ? GIT_TIMEOUT_MS : deadline - Date.now(); + if (remaining <= 0) throw new DeadlineExceededError("delegation deadline exceeded"); + let controller = null; + const result = await runCommand("git", args, { + cwd, + stdinText, + timeoutMs: Math.max(1, Math.min(GIT_TIMEOUT_MS, remaining)), + killGraceMs: 1_000, + shouldCancel: () => Boolean(cancel?.cancelled), + onChild: (current) => { + controller = current; + if (cancel) cancel.controller = current; + }, + }); + if (cancel?.controller === controller) cancel.controller = null; + 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; +} + +async function gitSnapshot(worktreeRoot, options = {}) { const jobs = [ ["git status --short", "status", ["status", "--short"]], ["git diff --stat", "diffStat", ["diff", "--stat"]], @@ -409,7 +437,7 @@ async function gitSnapshot(workspacePath) { // processes can race for .git/index.lock on the same repository. const results = []; for (const job of jobs) { - results.push(await runCommand("git", job[2], { cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS })); + results.push(await runGitCommand(job[2], { cwd: worktreeRoot, ...options })); } const failures = []; const out = {}; @@ -445,15 +473,15 @@ async function gitSnapshot(workspacePath) { }; } -async function committedDelta(workspacePath, beforeHead, afterHead) { +async function committedDelta(worktreeRoot, beforeHead, afterHead, options = {}) { if (!afterHead || beforeHead === afterHead) return null; let range; if (beforeHead) { range = beforeHead + ".." + afterHead; } else { - const emptyTree = await runCommand("git", ["mktree"], { - cwd: workspacePath, - timeoutMs: GIT_TIMEOUT_MS, + const emptyTree = await runGitCommand(["mktree"], { + cwd: worktreeRoot, + ...options, stdinText: "", }); const failure = snapshotFailure("git mktree", emptyTree); @@ -462,11 +490,11 @@ async function committedDelta(workspacePath, beforeHead, afterHead) { } range = emptyTree.stdout.trim() + ".." + afterHead; } - const log = await runCommand("git", ["log", "--oneline", beforeHead ? range : afterHead], { - cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS, + const log = await runGitCommand(["log", "--oneline", beforeHead ? range : afterHead], { + cwd: worktreeRoot, ...options, }); - const stat = await runCommand("git", ["diff", "--stat", range], { - cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS, + const stat = await runGitCommand(["diff", "--stat", range], { + cwd: worktreeRoot, ...options, }); const failures = [snapshotFailure("git log", log), snapshotFailure("git diff --stat", stat)].filter(Boolean); if (failures.length > 0) throw new Error("committed delta unreliable: " + failures.join("; ")); @@ -502,13 +530,26 @@ async function listBackends() { // serialized so workers cannot interleave edits or snapshot each other. const workspaceLocks = new Map(); const quarantinedWorkspaces = new Set(); -async function withWorkspaceLock(key, fn) { +async function withWorkspaceLock(key, fn, { cancel = null, onCancelled = null } = {}) { const prev = workspaceLocks.get(key) ?? Promise.resolve(); + const prevDone = prev.catch(() => {}); let release; const gate = new Promise((r) => { release = r; }); - const next = prev.catch(() => {}).then(() => gate); + const next = prevDone.then(() => gate); workspaceLocks.set(key, next); - await prev.catch(() => {}); + const acquired = cancel + ? await Promise.race([prevDone.then(() => true), cancel.promise.then(() => false)]) + : (await prevDone, true); + if (!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); + }); + return typeof onCancelled === "function" ? onCancelled() : undefined; + } try { return await fn(); } finally { @@ -517,6 +558,22 @@ async function withWorkspaceLock(key, fn) { } } +function createCancellation() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { + controller: null, + cancelled: false, + promise, + cancel() { + if (this.cancelled) return; + this.cancelled = true; + resolve(); + if (this.controller) void this.controller.terminate("cancelled"); + }, + }; +} + function cancelledDelegation({ backend, workspacePath, worktreeRoot, spec, before = null }) { return { ok: false, @@ -555,8 +612,12 @@ async function delegateTask(rawArgs, cancel) { await requireGitRepo(workspacePath); const worktreeRoot = await gitWorktreeRoot(workspacePath); const lockKey = workspaceLockKey(worktreeRoot); + const timeoutMs = Number.isInteger(rawArgs.timeoutMs) + ? Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, rawArgs.timeoutMs)) + : DEFAULT_TIMEOUT_MS; return await withWorkspaceLock(lockKey, async () => { + const deadline = Date.now() + timeoutMs; if (quarantinedWorkspaces.has(lockKey)) { return { ok: false, @@ -571,7 +632,25 @@ async function delegateTask(rawArgs, cancel) { return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); } const allowDirty = rawArgs.allowDirty === true; - const before = await gitSnapshot(worktreeRoot); + let before; + try { + before = await gitSnapshot(worktreeRoot, { cancel, deadline }); + } catch (error) { + 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 }); } @@ -604,9 +683,6 @@ async function delegateTask(rawArgs, cancel) { }; } - const timeoutMs = Number.isInteger(rawArgs.timeoutMs) - ? Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, rawArgs.timeoutMs)) - : DEFAULT_TIMEOUT_MS; const args = substituteArgs(template, rawArgs.task.trim(), rawArgs.resumeSessionId ?? ""); // Cancellation can arrive while this request waits for the mutex or while @@ -615,22 +691,56 @@ async function delegateTask(rawArgs, cancel) { return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec, before }); } + const 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), + }; + } + let workerController = null; const result = await runCommand(spec.command, args, { cwd: workspacePath, - timeoutMs, + timeoutMs: remaining, manageProcessTree: true, shouldCancel: () => Boolean(cancel && cancel.cancelled), - onChild: (controller) => { if (cancel) cancel.controller = controller; }, + onChild: (controller) => { + workerController = controller; + if (cancel) cancel.controller = controller; + }, }); + if (cancel?.controller === workerController) cancel.controller = null; if (!result.treeTerminated) quarantinedWorkspaces.add(lockKey); - const after = result.treeTerminated ? await gitSnapshot(worktreeRoot) : before; - const commits = result.treeTerminated ? await committedDelta(worktreeRoot, before.head, after.head) : null; + let after = null; + let commits = null; + let postRunDeadlineExceeded = false; + if (result.treeTerminated) { + try { + after = await gitSnapshot(worktreeRoot, { cancel, deadline }); + commits = await committedDelta(worktreeRoot, before.head, after.head, { cancel, deadline }); + } catch (error) { + 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; + } + } + } let error = ""; if (!result.treeTerminated) { error = "backend process tree could not be confirmed terminated; the workspace is quarantined until the bridge restarts"; } else if (cancel && cancel.cancelled) { - error = "delegation cancelled by client"; - } else if (result.timedOut) { + error = "delegation cancelled by client; post-run snapshot may be unavailable"; + } else if (result.timedOut || postRunDeadlineExceeded) { error = "backend \"" + backend + "\" timed out after " + timeoutMs + " ms" + (result.killed ? " and was force-killed" : ""); } else if (result.orphanedProcesses) { error = "backend exited while descendant processes were still running; the bridge terminated the remaining process tree"; @@ -645,7 +755,7 @@ async function delegateTask(rawArgs, cancel) { workspacePath, worktreeRoot, exitCode: result.exitCode, - timedOut: result.timedOut, + timedOut: result.timedOut || postRunDeadlineExceeded, killed: result.killed, cancelled: Boolean(cancel && cancel.cancelled), orphanedProcesses: result.orphanedProcesses, @@ -658,6 +768,9 @@ async function delegateTask(rawArgs, cancel) { commits, experimental: Boolean(spec.experimental), }; + }, { + cancel, + onCancelled: () => cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }), }); } @@ -693,6 +806,34 @@ function jsonRpcError(id, code, message) { return { jsonrpc: "2.0", id, error: { // notifications/cancelled can terminate the worker process. const activeRequests = new Map(); +async function terminateActiveRequests(reason = "shutdown") { + const waits = []; + for (const { cancel } of activeRequests.values()) { + const controller = cancel.controller; + cancel.cancel(); + if (controller) waits.push(controller.terminate(reason)); + } + await Promise.allSettled(waits); +} + +function installShutdownHandlers(stdin) { + let shuttingDown = false; + const shutdown = (exitCode) => { + if (shuttingDown) return; + shuttingDown = true; + 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)); + // 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(); + }); +} + async function handleMessage(message) { if (!message || typeof message !== "object" || message.jsonrpc !== "2.0") { return jsonRpcError(null, -32600, "Invalid JSON-RPC request"); @@ -701,10 +842,7 @@ async function handleMessage(message) { const requestId = message.params?.requestId ?? message.params?.id; const entry = activeRequests.get(requestId); if (entry && entry.cancel) { - entry.cancel.cancelled = true; - if (entry.cancel.controller) { - void entry.cancel.controller.terminate("cancelled"); - } + entry.cancel.cancel(); } return null; } @@ -749,16 +887,18 @@ async function handleMessage(message) { const workspacePath = await validateWorkspace(args.workspacePath); await requireGitRepo(workspacePath); const worktreeRoot = await gitWorktreeRoot(workspacePath); - const git = await withWorkspaceLock(workspaceLockKey(worktreeRoot), () => gitSnapshot(worktreeRoot)); - return jsonRpcResult(message.id, { - content: [{ type: "text", text: textResult("Workspace Status", { workspacePath, git }) }], - structuredContent: { ok: true, workspacePath, worktreeRoot, git }, + const lockKey = workspaceLockKey(worktreeRoot); + return await withWorkspaceLock(lockKey, async () => { + const git = await gitSnapshot(worktreeRoot); + return jsonRpcResult(message.id, { + content: [{ type: "text", text: textResult("Workspace Status", { workspacePath, worktreeRoot, git }) }], + structuredContent: { ok: true, workspacePath, worktreeRoot, git }, + }); }); } if (params.name === "delegate_task") { - const cancel = { controller: null, cancelled: false }; - const entry = { cancel }; - activeRequests.set(message.id, entry); + const cancel = createCancellation(); + activeRequests.set(message.id, { cancel }); try { const out = await delegateTask(args, cancel); return jsonRpcResult(message.id, { @@ -767,7 +907,7 @@ async function handleMessage(message) { isError: !out.ok, }); } finally { - if (activeRequests.get(message.id) === entry) activeRequests.delete(message.id); + activeRequests.delete(message.id); } } return jsonRpcError(message.id, -32602, "Unknown tool: " + params.name); @@ -809,4 +949,5 @@ function startStdioServer({ stdin = process.stdin, stdout = process.stdout } = { if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) { startStdioServer(); + installShutdownHandlers(process.stdin); } 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 index 09a6bfa..d6035d5 100644 --- a/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -50,8 +50,10 @@ inside the target git repository, and their results come back as a git diff for created are listed under changed files even though they do not appear in git diff --stat. - Run independent or comparison workers in separate clean Git worktrees at the same starting commit. A second run in one checkout inherits the first run's edits and is not independent. -- Timeouts: the default is 20 minutes; adjust timeoutMs for very large tasks. A timed-out worker - has its complete process tree terminated before the workspace lock is released. +- Timeouts: the default is 20 minutes; adjust timeoutMs for very large tasks. The deadline starts + after lock acquisition and covers 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 quarantines the worktree and blocks another worker until restart. The workspace may still contain diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index d9fae91..881d5cd 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -13,6 +13,7 @@ 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); class McpClient { constructor(configPath) { @@ -25,10 +26,10 @@ class McpClient { const lines = createInterface({ input: this.child.stdout, crlfDelay: Infinity }); lines.on("line", (line) => { const message = JSON.parse(line); - const entry = this.pending.get(message.id); + const entry = this.pending.get(requestKey(message.id)); if (!entry) return; clearTimeout(entry.timer); - this.pending.delete(message.id); + this.pending.delete(requestKey(message.id)); entry.resolve(message); }); this.child.on("exit", (code) => { @@ -53,10 +54,10 @@ class McpClient { request(method, params, id = this.nextId++) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { - this.pending.delete(id); + this.pending.delete(requestKey(id)); reject(new Error("timed out waiting for request " + String(id) + ": " + this.stderr)); }, 25_000); - this.pending.set(id, { resolve, reject, timer }); + this.pending.set(requestKey(id), { resolve, reject, timer }); this.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); }); } @@ -70,6 +71,16 @@ class McpClient { 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(); + await Promise.race([ + exited, + new Promise((_, reject) => setTimeout(() => reject(new Error("server did not exit after stdin closed")), 15_000)), + ]); + } } function execServer(configPath) { @@ -174,117 +185,34 @@ test("canonical Git worktree locking serializes root and symlink paths", async ( assert.equal(firstResponse.result.structuredContent.worktreeRoot, secondResponse.result.structuredContent.worktreeRoot); }); -test("workspace status reports NUL-delimited root-relative paths from a subdirectory", 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 response = await client.request("tools/call", { - name: "workspace_status", - arguments: { workspacePath: nested }, - }); - const changed = response.result.structuredContent.git.changedFiles; - assert.ok(changed.includes("nested/deep/ leading.txt"), JSON.stringify(changed)); - assert.ok(changed.includes("nested/deep/café-staged.txt"), JSON.stringify(changed)); - assert.ok(changed.includes("nested/deep/café-untracked.txt"), JSON.stringify(changed)); - assert.ok(changed.includes("root-new.txt"), JSON.stringify(changed)); - assert.equal(response.result.structuredContent.worktreeRoot, await realpath(workspace)); -}); - -test("workspace status waits for an active delegation on the canonical worktree lock", 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: "writer", eventFile, delayMs: 900, writeFile: "finished.txt", - }), 311); - await waitFor(async () => (await events(eventFile)).some((item) => item.event === "start")); - - const status = client.request("tools/call", { - name: "workspace_status", - arguments: { workspacePath: nested }, - }, 312); - const early = await Promise.race([ - status.then(() => "completed"), - new Promise((resolve) => setTimeout(() => resolve("pending"), 250)), - ]); - assert.equal(early, "pending", "status must wait while the delegation owns the workspace 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("cancellation distinguishes numeric and string JSON-RPC request ids", async (context) => { - const { tempRoot, workspace, client } = await makeHarness(context); - const other = path.join(tempRoot, "workspace-other"); - 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"), "baseline\n"); - await execFileAsync("git", ["add", "baseline.txt"], { cwd: other }); - await execFileAsync("git", ["commit", "-m", "baseline"], { cwd: other }); - const eventFile = path.join(tempRoot, "id-events.jsonl"); - - const numeric = client.request("tools/call", taskArguments(workspace, { - name: "numeric", eventFile, delayMs: 1_200, writeFile: "numeric.txt", - }), 313); - const string = client.request("tools/call", taskArguments(other, { - name: "string", eventFile, delayMs: 1_200, writeFile: "string.txt", - }), "313"); - 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: 313, reason: "numeric only" }); - - const [numericResponse, stringResponse] = await Promise.all([numeric, string]); - assert.equal(numericResponse.result.structuredContent.cancelled, true); - assert.equal(stringResponse.result.structuredContent.cancelled, false); - assert.equal(stringResponse.result.structuredContent.ok, true); -}); - -test("PowerShell runner returns nonzero when the backend command is missing", { - skip: process.platform !== "win32", -}, async () => { - const runner = path.join(pluginRoot, "ps1-runner.ps1"); - await assert.rejects(execFileAsync("powershell.exe", [ - "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", runner, - "cli-agent-bridge-command-that-does-not-exist-7f0d0f5b", - ]), (error) => error.code === 127); -}); - 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: 600, + 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 [firstResponse, queuedResponse] = await Promise.all([first, queued]); + 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); }); @@ -351,3 +279,119 @@ test("workspace status and delegation support an unborn HEAD", async (context) = assert.equal(delegated.result.structuredContent.gitBefore.head, ""); assert.deepEqual(delegated.result.structuredContent.git.changedFiles, ["created.txt"]); }); + +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("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("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("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); +}); From f46431a5bb09c1515bc36c990eebf25fdabd22a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 12:23:24 +0800 Subject: [PATCH 12/40] fix(cli-agent-bridge): serialize across server processes --- plugins/Hylouis233/cli-agent-bridge/README.md | 22 +- .../cli-agent-bridge/process-tree.mjs | 38 ++++ .../Hylouis233/cli-agent-bridge/server.mjs | 208 ++++++++++++++++-- .../skills/cli-agent-bridge/SKILL.md | 6 +- .../cli-agent-bridge/tests/server.test.mjs | 76 ++++++- 5 files changed, 320 insertions(+), 30 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 0c7781b..e5da1cc 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -18,8 +18,8 @@ repository: - 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; dirty trees are refused unless allowDirty=true; cancellation and timeout - terminate the complete worker process tree before the workspace lock is released. + 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. @@ -114,13 +114,18 @@ you already obtained a valid ID from that backend outside this Plugin. plays that role instead. - The bridge delegates tasks; it does not merge code, commit, or push. The user reviews every diff. -- Delegations targeting the same canonical Git worktree are serialized even when callers name a - subdirectory, different path casing, or symlink. Independent comparison runs still require - separate clean worktrees. +- Delegations and status snapshots targeting the same canonical Git worktree are serialized even + when callers name a subdirectory, different path casing, or symlink, and even when separate MCP + clients launched separate bridge server processes. Independent comparison runs still require + separate clean worktrees. The cross-process lock lives under the OS temporary directory; a lock + whose owner process exited is reclaimed before the next request starts. - Cancellation and timeout confirm that the delegated process tree has exited before releasing the workspace mutex. If termination cannot be confirmed, the bridge quarantines that worktree and refuses further delegations until the server is restarted and leftover processes are - checked. + checked. On Linux, zombie-only process groups count as terminated; zombies cannot edit the + workspace and may otherwise persist when container PID 1 does not reap them. +- 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 starts after the workspace lock is acquired and covers preflight Git checks, the worker, and post-run snapshots. Safe process-tree termination can extend beyond that deadline by the documented kill grace period. @@ -138,8 +143,9 @@ node --test plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs node --test plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs ``` -They cover the full MCP flow plus canonical worktree locking, queued cancellation, cancel/timeout -process-tree termination, unborn HEAD snapshots, and Codex prompt delimiters on Windows and POSIX. +They cover the full MCP flow plus in-process and cross-process canonical worktree locking, queued +delegation/status cancellation, cancel/timeout process-tree termination, unborn HEAD snapshots, +and Codex prompt delimiters on Windows and POSIX. ## License diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs index b943006..367c13c 100644 --- a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { readdir, readFile } from "node:fs/promises"; const UTILITY_CAPTURE_CHARS = 1_000_000; @@ -73,11 +74,48 @@ async function windowsProcessTreePids(rootPid, knownPids = new Set()) { return [...descendants]; } +async function linuxProcessGroupHasLiveMembers(processGroupId) { + let entries; + try { + entries = await readdir("/proc", { withFileTypes: true }); + } catch { + return null; + } + let incomplete = false; + for (const entry of entries) { + if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue; + let raw; + try { + raw = await readFile("/proc/" + entry.name + "/stat", "utf8"); + } catch (error) { + if (error.code === "ENOENT") continue; // process exited during the scan + incomplete = true; + continue; + } + // /proc/PID/stat starts with `pid (comm) state ppid pgrp ...`; comm can + // contain spaces and parentheses, so split only after its final `)`. + const close = raw.lastIndexOf(")"); + if (close < 0) { incomplete = true; continue; } + const fields = raw.slice(close + 1).trim().split(/\s+/u); + const state = fields[0]; + const pgrp = Number(fields[2]); + if (pgrp === processGroupId && state !== "Z" && state !== "X" && state !== "x") { + return true; + } + } + // When procfs is partially hidden, fall back to kill(0) and fail safe. + return incomplete ? null : false; +} + export async function isProcessTreeAlive(child, treeState) { if (!Number.isInteger(child.pid)) return false; if (process.platform === "win32") { return (await windowsProcessTreePids(child.pid, treeState.knownPids)).length > 0; } + if (process.platform === "linux") { + const live = await linuxProcessGroupHasLiveMembers(child.pid); + if (live !== null) return live; + } try { process.kill(-child.pid, 0); return true; diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 8422a07..522c0ef 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -7,7 +7,9 @@ // License: MIT. See NOTICE for upstream credits. import { spawn } from "node:child_process"; -import { readFile, realpath, stat } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { link, mkdir, readFile, realpath, stat, unlink, writeFile } from "node:fs/promises"; +import os from "node:os"; import { fileURLToPath } from "node:url"; import path from "node:path"; @@ -24,6 +26,8 @@ const GIT_TIMEOUT_MS = 30_000; const KILL_GRACE_MS = 10_000; const MAX_CAPTURE_CHARS = 5_000_000; const RAW_TAIL_CHARS = 60_000; +const WORKSPACE_LOCK_RETRY_MS = 50; +const WORKSPACE_LOCK_ROOT = path.join(os.tmpdir(), "minimax-cli-agent-bridge-locks"); // Built-in defaults. The sibling backends.json (or the CLI_AGENT_BRIDGE_BACKENDS // environment variable) overrides these; a missing or invalid file falls back @@ -100,7 +104,7 @@ const TOOLS = [ 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.", + "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, @@ -526,8 +530,107 @@ async function listBackends() { return entries; } -// Per-workspace mutex: concurrent delegations to the same checkout are -// serialized so workers cannot interleave edits or snapshot each other. +function workspaceFileLockPath(key) { + const digest = createHash("sha256").update(key).digest("hex"); + return path.join(WORKSPACE_LOCK_ROOT, digest + ".lock"); +} + +async function processIsAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + if (process.platform === "linux") { + try { + const raw = await readFile("/proc/" + String(pid) + "/stat", "utf8"); + const close = raw.lastIndexOf(")"); + const state = close < 0 ? "" : raw.slice(close + 1).trim().split(/\s+/u)[0]; + if (state === "Z" || state === "X" || state === "x") return false; + } catch (error) { + if (error.code === "ENOENT") return false; + // Fall through to kill(0) when procfs is unavailable or restricted. + } + } + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code === "EPERM"; + } +} + +async function reclaimDeadWorkspaceFileLock(lockPath) { + let observed; + try { + observed = await readFile(lockPath, "utf8"); + } catch (error) { + if (error.code === "ENOENT") return true; + throw error; + } + let owner; + try { + owner = JSON.parse(observed); + } catch { + // Lock records are linked into place only after a complete metadata file + // has been written, so malformed content is not safe to reclaim blindly. + return false; + } + if (await processIsAlive(Number(owner.pid))) return false; + try { + // Re-read before removal so a lock that changed owners is never deleted. + if (await readFile(lockPath, "utf8") !== observed) return false; + await unlink(lockPath); + return true; + } catch (error) { + if (error.code === "ENOENT") return true; + throw error; + } +} + +async function waitForWorkspaceLockRetry(cancel) { + const retry = new Promise((resolve) => setTimeout(() => resolve(true), WORKSPACE_LOCK_RETRY_MS)); + return cancel + ? await Promise.race([retry, cancel.promise.then(() => false)]) + : await retry; +} + +async function acquireWorkspaceFileLock(key, cancel = null) { + await mkdir(WORKSPACE_LOCK_ROOT, { recursive: true, mode: 0o700 }); + const lockPath = workspaceFileLockPath(key); + const token = process.pid + "-" + randomUUID(); + const ownerPath = lockPath + ".owner-" + token; + const ownerText = JSON.stringify({ pid: process.pid, token, createdAt: Date.now() }); + await writeFile(ownerPath, ownerText, { flag: "wx", mode: 0o600 }); + try { + while (true) { + if (cancel?.cancelled) return null; + try { + // Hard-link creation is an atomic create-if-absent operation on both + // NTFS and POSIX filesystems. The linked metadata is already complete. + await link(ownerPath, lockPath); + let released = false; + return { + async release() { + if (released) return; + released = true; + try { + if (await readFile(lockPath, "utf8") === ownerText) await unlink(lockPath); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + }, + }; + } catch (error) { + if (error.code !== "EEXIST") throw error; + } + if (await reclaimDeadWorkspaceFileLock(lockPath)) continue; + if (!await waitForWorkspaceLockRetry(cancel)) return null; + } + } finally { + try { await unlink(ownerPath); } catch (error) { if (error.code !== "ENOENT") throw error; } + } +} + +// The in-memory queue preserves FIFO order within this server. The atomic +// filesystem lock extends the same canonical-worktree mutex across independent +// stdio server processes, so two MCP clients cannot interleave workers. const workspaceLocks = new Map(); const quarantinedWorkspaces = new Set(); async function withWorkspaceLock(key, fn, { cancel = null, onCancelled = null } = {}) { @@ -550,9 +653,17 @@ async function withWorkspaceLock(key, fn, { cancel = null, onCancelled = null } }); return typeof onCancelled === "function" ? onCancelled() : undefined; } + let fileLock = null; try { + fileLock = await acquireWorkspaceFileLock(key, cancel); + if (!fileLock || cancel?.cancelled) { + if (fileLock) await fileLock.release(); + fileLock = null; + return typeof onCancelled === "function" ? onCancelled() : undefined; + } return await fn(); } finally { + if (fileLock) await fileLock.release(); release(); if (workspaceLocks.get(key) === next) workspaceLocks.delete(key); } @@ -595,6 +706,22 @@ function cancelledDelegation({ backend, workspacePath, worktreeRoot, spec, befor }; } +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, + }); +} + async function delegateTask(rawArgs, cancel) { const backends = await loadBackends(); if (!rawArgs || typeof rawArgs.backend !== "string" || !rawArgs.backend.trim()) { @@ -702,6 +829,11 @@ async function delegateTask(rawArgs, cancel) { 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; const result = await runCommand(spec.command, args, { cwd: workspacePath, @@ -802,18 +934,32 @@ function textResult(header, obj) { 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 delegate_task requests, keyed by JSON-RPC request id, so a -// notifications/cancelled can terminate the worker process. +// 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(); +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 activeRequests.values()) { + 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)); } function installShutdownHandlers(stdin) { @@ -884,21 +1030,45 @@ async function handleMessage(message) { }); } if (params.name === "workspace_status") { - const workspacePath = await validateWorkspace(args.workspacePath); - await requireGitRepo(workspacePath); - const worktreeRoot = await gitWorktreeRoot(workspacePath); - const lockKey = workspaceLockKey(worktreeRoot); - return await withWorkspaceLock(lockKey, async () => { - const git = await gitSnapshot(worktreeRoot); - return jsonRpcResult(message.id, { - content: [{ type: "text", text: textResult("Workspace Status", { workspacePath, worktreeRoot, git }) }], - structuredContent: { ok: true, workspacePath, worktreeRoot, git }, + const cancel = createCancellation(); + const finishRequest = trackActiveRequest(message.id, cancel); + let workspacePath = ""; + let worktreeRoot = ""; + try { + workspacePath = await validateWorkspace(args.workspacePath); + if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath }); + await requireGitRepo(workspacePath); + if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath }); + worktreeRoot = await gitWorktreeRoot(workspacePath); + if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); + const lockKey = workspaceLockKey(worktreeRoot); + return await withWorkspaceLock(lockKey, async () => { + try { + const git = await gitSnapshot(worktreeRoot, { cancel }); + 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 OperationCancelledError) { + return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); + } + throw error; + } + }, { + cancel, + onCancelled: () => cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }), }); - }); + } finally { + finishRequest(); + } } if (params.name === "delegate_task") { const cancel = createCancellation(); - activeRequests.set(message.id, { cancel }); + const finishRequest = trackActiveRequest(message.id, cancel); try { const out = await delegateTask(args, cancel); return jsonRpcResult(message.id, { @@ -907,7 +1077,7 @@ async function handleMessage(message) { isError: !out.ok, }); } finally { - activeRequests.delete(message.id); + finishRequest(); } } return jsonRpcError(message.id, -32602, "Unknown tool: " + params.name); 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 index d6035d5..f6c5fc8 100644 --- a/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -21,8 +21,8 @@ inside the target git repository, and their results come back as a git diff for 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 to the same workspace are serialized by the server, so parallel runs on one - checkout queue instead of interleaving edits. + Delegations to the same workspace are serialized across bridge server processes, so parallel + runs from separate MCP clients still queue instead of interleaving edits. 4. Review the returned result: the before and after git snapshots (status, diff stat, changed files including staged and new files), the commits block when the worker committed, the output and stderr tails, and the exit code. A failed, timed-out, or cancelled run reports @@ -58,6 +58,8 @@ inside the target git repository, and their results come back as a git diff for tree and the result reports cancelled=true. If tree termination cannot be confirmed, the bridge quarantines the worktree and blocks another worker until restart. The workspace may still contain edits made before cancellation, so still review the returned snapshot. +- 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. ## Notes diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index 881d5cd..6c9e0ae 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import { access, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -121,7 +122,7 @@ async function makeHarness(context, { unborn = false } = {}) { await client.close(); await rm(tempRoot, { recursive: true, force: true }); }); - return { tempRoot, workspace, client }; + return { tempRoot, workspace, configPath, client }; } function taskArguments(workspacePath, spec, extra = {}) { @@ -185,6 +186,56 @@ test("canonical Git worktree locking serializes root and symlink paths", async ( assert.equal(firstResponse.result.structuredContent.worktreeRoot, secondResponse.result.structuredContent.worktreeRoot); }); +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 workspace lock left by a dead server process is reclaimed", async (context) => { + const { workspace, client } = await makeHarness(context); + const canonicalRoot = await realpath(workspace); + const normalized = path.normalize(canonicalRoot); + const key = "git-worktree:" + (process.platform === "win32" ? normalized.toLowerCase() : normalized); + const digest = createHash("sha256").update(key).digest("hex"); + const lockRoot = path.join(os.tmpdir(), "minimax-cli-agent-bridge-locks"); + const lockPath = path.join(lockRoot, digest + ".lock"); + await mkdir(lockRoot, { recursive: true }); + context.after(() => rm(lockPath, { force: true })); + await writeFile(lockPath, JSON.stringify({ + pid: 99_999_999, + token: "dead-server-fixture", + createdAt: Date.now() - 60_000, + })); + + const response = await client.request("tools/call", taskArguments(workspace, { + name: "after-stale-lock", writeFile: "reclaimed.txt", + })); + assert.equal(response.result.structuredContent.ok, true); + await assert.rejects(access(lockPath), /ENOENT/u); +}); + 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"); @@ -362,6 +413,29 @@ test("workspace_status waits for an active delegation on the same worktree", asy 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("closing MCP stdin terminates active worker descendants", async (context) => { const { tempRoot, workspace, client } = await makeHarness(context); const eventFile = path.join(tempRoot, "shutdown-events.jsonl"); From c7a4a15ba8076ccdeda1f9f175346cccfa128d2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 12:27:55 +0800 Subject: [PATCH 13/40] fix(cli-agent-bridge): harden Linux zombie cleanup --- plugins/Hylouis233/cli-agent-bridge/README.md | 5 +- .../cli-agent-bridge/process-tree.mjs | 78 +++++++++++------- .../Hylouis233/cli-agent-bridge/server.mjs | 2 +- .../tests/process-tree.test.mjs | 81 +++++++++++++++++++ 4 files changed, 133 insertions(+), 33 deletions(-) create mode 100644 plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index e5da1cc..6c22b68 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -144,8 +144,9 @@ node --test plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs ``` They cover the full MCP flow plus in-process and cross-process canonical worktree locking, queued -delegation/status cancellation, cancel/timeout process-tree termination, unborn HEAD snapshots, -and Codex prompt delimiters on Windows and POSIX. +delegation/status cancellation, cancel/timeout process-tree termination, zombie-only Linux groups, +unusual Git pathnames, JSON-RPC id typing, unborn HEAD snapshots, and Codex prompt delimiters on +Windows and POSIX. ## License diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs index 367c13c..dfeed14 100644 --- a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs @@ -74,54 +74,72 @@ async function windowsProcessTreePids(rootPid, knownPids = new Set()) { return [...descendants]; } -async function linuxProcessGroupHasLiveMembers(processGroupId) { +// 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 }, +) { let entries; try { - entries = await readdir("/proc", { withFileTypes: true }); + entries = await fsOps.readdir(procRoot, { withFileTypes: true }); } catch { return null; } - let incomplete = false; + let sawMember = false; for (const entry of entries) { if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue; - let raw; + let statLine; try { - raw = await readFile("/proc/" + entry.name + "/stat", "utf8"); + statLine = await fsOps.readFile(`${procRoot}/${entry.name}/stat`, "utf8"); } catch (error) { - if (error.code === "ENOENT") continue; // process exited during the scan - incomplete = true; - continue; + if (error.code === "ENOENT") continue; + return null; } - // /proc/PID/stat starts with `pid (comm) state ppid pgrp ...`; comm can - // contain spaces and parentheses, so split only after its final `)`. - const close = raw.lastIndexOf(")"); - if (close < 0) { incomplete = true; continue; } - const fields = raw.slice(close + 1).trim().split(/\s+/u); + // 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 state = fields[0]; - const pgrp = Number(fields[2]); - if (pgrp === processGroupId && state !== "Z" && state !== "X" && state !== "x") { - return true; - } + const group = Number(fields[2]); + if (!state || !Number.isInteger(group)) return null; + if (group !== processGroupId) continue; + sawMember = true; + if (state !== "Z" && state !== "X" && state !== "x") return true; } - // When procfs is partially hidden, fall back to kill(0) and fail safe. - return incomplete ? null : false; + return sawMember ? false : null; } -export async function isProcessTreeAlive(child, treeState) { +export async function isProcessTreeAlive(child, treeState, { + ignoreZombieOnly = false, + platform = process.platform, + procRoot = "/proc", + fsOps = { readdir, readFile }, + probeProcessGroup = (processGroupId) => process.kill(-processGroupId, 0), +} = {}) { if (!Number.isInteger(child.pid)) return false; - if (process.platform === "win32") { + if (platform === "win32") { return (await windowsProcessTreePids(child.pid, treeState.knownPids)).length > 0; } - if (process.platform === "linux") { - const live = await linuxProcessGroupHasLiveMembers(child.pid); - if (live !== null) return live; - } try { - process.kill(-child.pid, 0); - return true; + probeProcessGroup(child.pid); } catch (error) { - return error.code === "EPERM"; + // Only ESRCH is a reliable negative result. Permission and unexpected + // probe errors fail safe so callers quarantine rather than reuse a live + // workspace. + 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) { @@ -145,9 +163,9 @@ export async function signalProcessTree(child, signal, treeState) { } } -export async function waitForProcessTreeExit(child, timeoutMs, treeState) { +export async function waitForProcessTreeExit(child, timeoutMs, treeState, options = {}) { const deadline = Date.now() + timeoutMs; - while (await isProcessTreeAlive(child, treeState)) { + while (await isProcessTreeAlive(child, treeState, options)) { if (Date.now() >= deadline) return false; await new Promise((resolve) => setTimeout(resolve, 50)); } diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 522c0ef..3616b82 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -268,7 +268,7 @@ async function runCommand(command, args, options = {}) { if (manageProcessTree) await signalProcessTree(child, "SIGKILL", treeState); else try { child.kill("SIGKILL"); } catch { /* already gone */ } treeTerminated = manageProcessTree - ? await waitForProcessTreeExit(child, killGraceMs, treeState) + ? await waitForProcessTreeExit(child, killGraceMs, treeState, { ignoreZombieOnly: true }) : await waitForChildExit(child, killGraceMs); if (!treeTerminated) { terminationError = "process tree still appears alive after forceful termination"; 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..9e47a1b --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + isProcessTreeAlive, + linuxProcessGroupHasLiveMembers, + waitForProcessTreeExit, +} from "../process-tree.mjs"; + +async function writeProcStat(root, pid, { state, group, command = "worker" }) { + const directory = path.join(root, String(pid)); + await mkdir(directory); + await writeFile(path.join(directory, "stat"), `${pid} (${command}) ${state} 1 ${group} ${group} 0 0 0 0\n`); +} + +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]) }; + 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); +}); From 8f599d2bb27e37e737360e353ad33679a736aea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 13:28:57 +0800 Subject: [PATCH 14/40] fix(cli-agent-bridge): harden cross-process isolation --- plugins/Hylouis233/cli-agent-bridge/README.md | 35 +- .../cli-agent-bridge/process-tree.mjs | 140 ++++++-- .../Hylouis233/cli-agent-bridge/server.mjs | 324 +++++++++++++++--- .../skills/cli-agent-bridge/SKILL.md | 10 +- .../cli-agent-bridge/tests/fake-backend.mjs | 23 +- .../cli-agent-bridge/tests/server.test.mjs | 194 ++++++++++- 6 files changed, 633 insertions(+), 93 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 6c22b68..bfe5ba1 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -88,13 +88,15 @@ underlying real executable in backends.json. ## Data and network -- This Plugin makes no network calls of its own and stores no credentials, tokens, or logs. +- 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 removes it. - 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 captures only the command output and the resulting git diff; nothing is transmitted - anywhere by the server itself. +- 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. @@ -117,18 +119,24 @@ you already obtained a valid ID from that backend outside this Plugin. - Delegations and status snapshots targeting the same canonical Git worktree are serialized even when callers name a subdirectory, different path casing, or symlink, and even when separate MCP clients launched separate bridge server processes. Independent comparison runs still require - separate clean worktrees. The cross-process lock lives under the OS temporary directory; a lock - whose owner process exited is reclaimed before the next request starts. + separate clean worktrees. The cross-process lock lives in a current-user-scoped directory under + the OS temporary directory. Dead-owner recovery compares both PID and process-start identity; + an atomic reclaim claim prevents two waiters from deleting a newly acquired lock. - Cancellation and timeout confirm that the delegated process tree has exited before releasing - the workspace mutex. If termination cannot be confirmed, the bridge quarantines that worktree - and refuses further delegations until the server is restarted and leftover processes are - checked. On Linux, zombie-only process groups count as terminated; zombies cannot edit the - workspace and may otherwise persist when container PID 1 does not reap them. + the workspace mutex. A lightweight ancestry monitor records descendants that create a new POSIX + session/process group so cancellation still terminates them. If termination cannot be confirmed, + the bridge writes a shared quarantine marker and every bridge process refuses further delegation + until an operator checks for leftovers and deliberately removes the reported quarantinePath. + On Linux, zombie-only tracked trees count as terminated; zombies cannot edit the workspace and + may otherwise persist when container PID 1 does not reap them. - 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 starts after the workspace lock is acquired and covers 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. 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 @@ -143,10 +151,11 @@ node --test plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs node --test plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs ``` -They cover the full MCP flow plus in-process and cross-process canonical worktree locking, queued -delegation/status cancellation, cancel/timeout process-tree termination, zombie-only Linux groups, -unusual Git pathnames, JSON-RPC id typing, unborn HEAD snapshots, and Codex prompt delimiters on -Windows and POSIX. +They cover the full MCP flow plus in-process and cross-process canonical worktree locking, atomic +stale-lock/PID-reuse recovery, shared quarantine markers, queued and discovery-phase cancellation, +cancel/timeout process-tree termination, escaped POSIX descendants and zombie-only Linux groups, +unusual Git pathnames, JSON-RPC id typing, unborn HEAD and non-HEAD ref changes, capture truncation, +and Codex prompt delimiters on Windows and POSIX. ## License diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs index dfeed14..d72ea8f 100644 --- a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs @@ -74,24 +74,14 @@ async function windowsProcessTreePids(rootPid, knownPids = new Set()) { return [...descendants]; } -// 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 }, -) { +async function linuxProcessSnapshot(procRoot = "/proc", fsOps = { readdir, readFile }) { let entries; try { entries = await fsOps.readdir(procRoot, { withFileTypes: true }); } catch { return null; } - let sawMember = false; + const processes = []; for (const entry of entries) { if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue; let statLine; @@ -106,14 +96,100 @@ export async function linuxProcessGroupHasLiveMembers( const close = statLine.lastIndexOf(")"); if (close === -1) return null; const fields = statLine.slice(close + 1).trim().split(/\s+/u); - const state = fields[0]; - const group = Number(fields[2]); - if (!state || !Number.isInteger(group)) return null; - if (group !== processGroupId) continue; - sawMember = true; - if (state !== "Z" && state !== "X" && state !== "x") return true; - } - return sawMember ? false : null; + const item = { + pid: Number(entry.name), + state: fields[0], + parentPid: Number(fields[1]), + processGroupId: Number(fields[2]), + startIdentity: fields[19] ?? "", // field 22: start time since boot + }; + if (!item.state || !Number.isInteger(item.pid) || + !Number.isInteger(item.parentPid) || !Number.isInteger(item.processGroupId)) return null; + processes.push(item); + } + 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 posixProcessSnapshot({ + platform = process.platform, + procRoot = "/proc", + fsOps = { readdir, readFile }, +} = {}) { + if (platform === "linux") return await linuxProcessSnapshot(procRoot, fsOps); + const result = await runUtility("ps", ["-axo", "pid=,ppid=,pgid=,stat="]); + if (result.exitCode !== 0) return null; + const processes = result.stdout.split(/\r?\n/u).flatMap((line) => { + const fields = line.trim().split(/\s+/u); + if (fields.length < 4) return []; + return [{ + pid: Number(fields[0]), + parentPid: Number(fields[1]), + processGroupId: Number(fields[2]), + state: fields[3][0] ?? "", + startIdentity: "", + }]; + }).filter((item) => Number.isInteger(item.pid)); + processes.incomplete = false; + return processes; +} + +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.knownPids); + return null; + } + const processes = await posixProcessSnapshot(options); + if (processes === null) return null; + treeState.knownStarts ??= new Map(); + const byPid = new Map(processes.map((item) => [item.pid, item])); + const matchesKnownIdentity = (item) => { + const expected = treeState.knownStarts.get(item.pid); + return !expected || !item.startIdentity || expected === item.startIdentity; + }; + const parents = new Set([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 ((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, { @@ -127,6 +203,19 @@ export async function isProcessTreeAlive(child, treeState, { if (platform === "win32") { return (await windowsProcessTreePids(child.pid, treeState.knownPids)).length > 0; } + const processes = await refreshProcessTree(child, treeState, { platform, procRoot, fsOps }); + if (processes !== null) { + const knownStarts = treeState.knownStarts ?? new Map(); + const trackedLive = processes.some((item) => { + if (!isLiveState(item.state)) return false; + if (item.processGroupId === child.pid) return true; + if (!treeState.knownPids.has(item.pid)) return false; + const expected = knownStarts.get(item.pid); + return !expected || !item.startIdentity || expected === item.startIdentity; + }); + if (trackedLive) return true; + if (ignoreZombieOnly && !processes.incomplete) return false; + } try { probeProcessGroup(child.pid); } catch (error) { @@ -156,11 +245,22 @@ export async function signalProcessTree(child, signal, treeState) { } return; } + await refreshProcessTree(child, treeState); try { process.kill(-child.pid, signal); } catch (error) { if (error.code !== "ESRCH") throw error; } + const processes = await posixProcessSnapshot(); + const byPid = processes === null ? new Map() : new Map(processes.map((item) => [item.pid, item])); + for (const pid of [...treeState.knownPids].reverse()) { + if (pid === child.pid) continue; + const item = byPid.get(pid); + const expected = treeState.knownStarts?.get(pid); + if (item && expected && item.startIdentity && expected !== item.startIdentity) continue; + try { process.kill(pid, signal); } + catch (error) { if (error.code !== "ESRCH") throw error; } + } } export async function waitForProcessTreeExit(child, timeoutMs, treeState, options = {}) { diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 3616b82..6be788c 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -13,7 +13,7 @@ import os from "node:os"; import { fileURLToPath } from "node:url"; import path from "node:path"; -import { isProcessTreeAlive, signalProcessTree, waitForChildExit, waitForProcessTreeExit } from "./process-tree.mjs"; +import { isProcessTreeAlive, refreshProcessTree, signalProcessTree, waitForChildExit, waitForProcessTreeExit } from "./process-tree.mjs"; const SERVER_NAME = "cli-agent-bridge"; const SERVER_VERSION = "0.1.0"; @@ -27,7 +27,23 @@ const KILL_GRACE_MS = 10_000; const MAX_CAPTURE_CHARS = 5_000_000; const RAW_TAIL_CHARS = 60_000; const WORKSPACE_LOCK_RETRY_MS = 50; -const WORKSPACE_LOCK_ROOT = path.join(os.tmpdir(), "minimax-cli-agent-bridge-locks"); + +function currentUserLockScope() { + let identity; + try { + const user = os.userInfo(); + identity = Number.isInteger(user.uid) && user.uid >= 0 + ? process.platform + ":uid:" + String(user.uid) + : process.platform + ":" + user.username + ":" + user.homedir; + } catch { + identity = process.platform + ":" + (process.env.USERNAME ?? process.env.USER ?? os.homedir()); + } + return createHash("sha256").update(identity).digest("hex").slice(0, 20); +} + +const WORKSPACE_LOCK_ROOT = path.join( + os.tmpdir(), "minimax-cli-agent-bridge-locks-" + currentUserLockScope(), +); // Built-in defaults. The sibling backends.json (or the CLI_AGENT_BRIDGE_BACKENDS // environment variable) overrides these; a missing or invalid file falls back @@ -181,6 +197,7 @@ function substituteArgs(template, task, session) { function capture() { let chunks = []; let length = 0; + let truncated = false; return { push(chunk) { if (typeof chunk !== "string" || chunk.length === 0) return; @@ -189,9 +206,11 @@ function capture() { while (length > MAX_CAPTURE_CHARS && chunks.length > 0) { const dropped = chunks.shift(); length -= dropped.length; + truncated = true; } }, text() { return chunks.join(""); }, + truncated() { return truncated; }, }; } @@ -202,6 +221,7 @@ async function runCommand(command, args, options = {}) { stdout: "", stderr: "", exitCode: null, timedOut: false, killed: false, orphanedProcesses: false, treeTerminated: true, terminationError: "", errorMessage: "command cancelled before spawn", spawnError: null, + stdoutTruncated: false, stderrTruncated: false, }); return; } @@ -232,13 +252,26 @@ async function runCommand(command, args, options = {}) { let terminationError = ""; let exitCode = null; let terminationPromise = null; - const treeState = { knownPids: new Set(Number.isInteger(child.pid) ? [child.pid] : []) }; + const treeState = { + knownPids: new Set(Number.isInteger(child.pid) ? [child.pid] : []), + knownStarts: new Map(), + }; + let treeRefreshActive = false; + let treeRefreshTimer = null; + const refreshTree = async () => { + if (!manageProcessTree || treeRefreshActive) return; + treeRefreshActive = true; + try { await refreshProcessTree(child, treeState); } + catch (error) { terminationError ||= "process-tree inspection failed: " + error.message; } + finally { treeRefreshActive = false; } + }; const timeoutMs = options.timeoutMs ?? 30_000; const killGraceMs = options.killGraceMs ?? KILL_GRACE_MS; const settle = () => { if (settled) return; settled = true; clearTimeout(timer); + if (treeRefreshTimer) clearInterval(treeRefreshTimer); resolve({ stdout: stdoutBuf.text(), stderr: stderrBuf.text(), @@ -250,6 +283,8 @@ async function runCommand(command, args, options = {}) { terminationError, errorMessage: "", spawnError, + stdoutTruncated: stdoutBuf.truncated(), + stderrTruncated: stderrBuf.truncated(), }); }; @@ -286,6 +321,12 @@ async function runCommand(command, args, options = {}) { if (typeof options.onChild === "function" && Number.isInteger(child.pid)) { options.onChild({ child, terminate }); } + if (manageProcessTree && process.platform !== "win32") { + void refreshTree(); + const refreshIntervalMs = process.platform === "linux" ? 25 : 250; + treeRefreshTimer = setInterval(() => { void refreshTree(); }, refreshIntervalMs); + treeRefreshTimer.unref?.(); + } if (child.stdin) child.stdin.end(options.stdinText); const timer = setTimeout(() => { void terminate("timeout"); }, timeoutMs); @@ -300,6 +341,7 @@ async function runCommand(command, args, options = {}) { if (settled) return; settled = true; clearTimeout(timer); + if (treeRefreshTimer) clearInterval(treeRefreshTimer); resolve({ stdout: stdoutBuf.text(), stderr: stderrBuf.text(), @@ -311,6 +353,8 @@ async function runCommand(command, args, options = {}) { terminationError, errorMessage: error.message, spawnError, + stdoutTruncated: stdoutBuf.truncated(), + stderrTruncated: stderrBuf.truncated(), }); }); child.on("close", async (code) => { @@ -365,18 +409,18 @@ async function validateWorkspace(workspacePath) { return resolved; } -async function requireGitRepo(workspacePath) { - const result = await runCommand("git", ["rev-parse", "--is-inside-work-tree"], { - cwd: workspacePath, timeoutMs: 15_000, +async function requireGitRepo(workspacePath, cancel = null) { + const result = await runGitCommand(["rev-parse", "--is-inside-work-tree"], { + cwd: workspacePath, cancel, }); if (result.exitCode !== 0 || result.stdout.trim() !== "true") { throw new Error("workspacePath is not a git repository: " + workspacePath); } } -async function gitWorktreeRoot(workspacePath) { - const result = await runCommand("git", ["rev-parse", "--show-toplevel"], { - cwd: workspacePath, timeoutMs: GIT_TIMEOUT_MS, +async function gitWorktreeRoot(workspacePath, cancel = null) { + const result = await runGitCommand(["rev-parse", "--show-toplevel"], { + cwd: workspacePath, cancel, }); const failure = snapshotFailure("git rev-parse --show-toplevel", result); if (failure || !result.stdout.trim()) { @@ -396,6 +440,9 @@ function workspaceLockKey(worktreeRoot) { function snapshotFailure(label, result) { 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.exitCode !== 0) return label + " failed with exit code " + String(result.exitCode); return ""; } @@ -436,6 +483,7 @@ async function gitSnapshot(worktreeRoot, options = {}) { ["git diff --cached --name-only -z", "cachedDiffNames", ["diff", "--cached", "--name-only", "-z"]], ["git ls-files --others --exclude-standard -z", "untracked", ["ls-files", "--others", "--exclude-standard", "-z"]], ["git rev-parse --verify --quiet HEAD", "head", ["rev-parse", "--verify", "--quiet", "HEAD"], true], + ["git for-each-ref", "refs", ["for-each-ref", "--format=%(refname)%09%(objectname)", "refs"]], ]; // Run serially: status/diff may both refresh the index, so concurrent Git // processes can race for .git/index.lock on the same repository. @@ -469,20 +517,33 @@ async function gitSnapshot(worktreeRoot, options = {}) { .filter(Boolean) .map((s, i) => (i === 0 ? s : s.split(/\r?\n/).map((l) => "staged: " + l).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) refs[line.slice(0, separator)] = line.slice(separator + 1); + } return { statusShort: String(out.status ?? "").trim(), diffStat, changedFiles, head: String(out.head ?? "").trim(), + refs, }; } -async function committedDelta(worktreeRoot, beforeHead, afterHead, options = {}) { - if (!afterHead || beforeHead === afterHead) return null; - let range; - if (beforeHead) { - range = beforeHead + ".." + afterHead; - } else { +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 }]; + }); + if (before.head === after.head && refsChanged.length === 0) return null; + + let emptyTreeId = ""; + async function emptyTree() { + if (emptyTreeId) return emptyTreeId; const emptyTree = await runGitCommand(["mktree"], { cwd: worktreeRoot, ...options, @@ -492,20 +553,47 @@ async function committedDelta(worktreeRoot, beforeHead, afterHead, options = {}) if (failure || !emptyTree.stdout.trim()) { throw new Error("cannot compute committed delta from unborn HEAD: " + (failure || "empty tree id missing")); } - range = emptyTree.stdout.trim() + ".." + afterHead; + emptyTreeId = emptyTree.stdout.trim(); + return emptyTreeId; + } + + const ranges = new Map(); + async function addRange(label, beforeOid, afterOid) { + if (!afterOid || beforeOid === afterOid) return; + const base = beforeOid || before.head || await emptyTree(); + const range = base + ".." + afterOid; + const labels = ranges.get(range) ?? []; + labels.push(label); + ranges.set(range, labels); + } + await addRange("HEAD", before.head, after.head); + for (const change of refsChanged) { + await addRange(change.ref, change.before, change.after); + } + + const logs = []; + const stats = []; + for (const [range, labels] of ranges) { + const log = await runGitCommand(["log", "--oneline", range], { + cwd: worktreeRoot, ...options, + }); + const diff = await runGitCommand(["diff", "--stat", range], { + cwd: worktreeRoot, ...options, + }); + const failures = [ + snapshotFailure("git log " + range, log), + snapshotFailure("git diff --stat " + range, diff), + ].filter(Boolean); + if (failures.length > 0) throw new Error("committed delta unreliable: " + failures.join("; ")); + const heading = labels.join(", ") + " [" + range + "]"; + logs.push(heading + "\n" + (String(log.stdout ?? "").trim() || "(no new commits)")); + stats.push(heading + "\n" + (String(diff.stdout ?? "").trim() || "(empty)")); } - const log = await runGitCommand(["log", "--oneline", beforeHead ? range : afterHead], { - cwd: worktreeRoot, ...options, - }); - const stat = await runGitCommand(["diff", "--stat", range], { - cwd: worktreeRoot, ...options, - }); - const failures = [snapshotFailure("git log", log), snapshotFailure("git diff --stat", stat)].filter(Boolean); - if (failures.length > 0) throw new Error("committed delta unreliable: " + failures.join("; ")); return { - range, - log: String(log.stdout ?? "").trim(), - diffStat: String(stat.stdout ?? "").trim(), + range: [...ranges.keys()].join("\n"), + refsChanged, + log: logs.join("\n\n"), + diffStat: stats.join("\n\n"), }; } @@ -535,6 +623,44 @@ function workspaceFileLockPath(key) { return path.join(WORKSPACE_LOCK_ROOT, digest + ".lock"); } +function workspaceQuarantinePath(key) { + const digest = createHash("sha256").update(key).digest("hex"); + return path.join(WORKSPACE_LOCK_ROOT, digest + ".quarantine"); +} + +async function readWorkspaceQuarantine(key) { + const quarantinePath = workspaceQuarantinePath(key); + try { + const raw = await readFile(quarantinePath, "utf8"); + let details; + try { details = JSON.parse(raw); } catch { details = { error: "invalid quarantine record" }; } + return { quarantinePath, details }; + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +} + +async function markWorkspaceQuarantined(key, details) { + await mkdir(WORKSPACE_LOCK_ROOT, { recursive: true, mode: 0o700 }); + const quarantinePath = workspaceQuarantinePath(key); + const token = process.pid + "-" + randomUUID(); + const temporaryPath = quarantinePath + ".owner-" + token; + await writeFile(temporaryPath, JSON.stringify({ + ...details, + serverPid: process.pid, + processIdentity: await cachedProcessStartIdentity(process.pid), + quarantinedAt: new Date().toISOString(), + }), { flag: "wx", mode: 0o600 }); + try { + try { await link(temporaryPath, quarantinePath); } + catch (error) { if (error.code !== "EEXIST") throw error; } + } finally { + try { await unlink(temporaryPath); } catch (error) { if (error.code !== "ENOENT") throw error; } + } + return quarantinePath; +} + async function processIsAlive(pid) { if (!Number.isInteger(pid) || pid <= 0) return false; if (process.platform === "linux") { @@ -556,6 +682,64 @@ async function processIsAlive(pid) { } } +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-CimInstance Win32_Process -Filter 'ProcessId = " + String(pid) + + "'; if ($null -ne $p) { $p.CreationDate.ToUniversalTime().Ticks }"; + const result = await runCommand("powershell.exe", [ + "-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; +} + +async function lockOwnerMatchesLiveProcess(owner) { + const ownerPid = Number(owner.pid); + const currentIdentity = await cachedProcessStartIdentity(ownerPid); + if (currentIdentity) { + // Old lock records without an identity fail safe while their PID is live. + return !owner.processIdentity || owner.processIdentity === currentIdentity; + } + return await processIsAlive(ownerPid); +} + async function reclaimDeadWorkspaceFileLock(lockPath) { let observed; try { @@ -572,15 +756,35 @@ async function reclaimDeadWorkspaceFileLock(lockPath) { // has been written, so malformed content is not safe to reclaim blindly. return false; } - if (await processIsAlive(Number(owner.pid))) return false; + if (await lockOwnerMatchesLiveProcess(owner)) return false; + const claimPath = lockPath + ".reclaim"; try { - // Re-read before removal so a lock that changed owners is never deleted. - if (await readFile(lockPath, "utf8") !== observed) return false; + // Creating this hard link is an atomic single-reclaimer claim on the exact + // lock inode. Other contenders cannot authorize a concurrent unlink. + await link(lockPath, claimPath); + } catch (error) { + if (error.code === "ENOENT") return true; + if (error.code === "EEXIST") return false; + throw error; + } + try { + const [claimed, current, claimStat, lockStat] = await Promise.all([ + readFile(claimPath, "utf8"), + readFile(lockPath, "utf8"), + stat(claimPath), + stat(lockPath), + ]); + if (claimed !== observed || current !== observed || + claimStat.dev !== lockStat.dev || claimStat.ino !== lockStat.ino) return false; + const latestOwner = JSON.parse(current); + if (await lockOwnerMatchesLiveProcess(latestOwner)) return false; await unlink(lockPath); return true; } catch (error) { if (error.code === "ENOENT") return true; throw error; + } finally { + try { await unlink(claimPath); } catch (error) { if (error.code !== "ENOENT") throw error; } } } @@ -596,7 +800,10 @@ async function acquireWorkspaceFileLock(key, cancel = null) { const lockPath = workspaceFileLockPath(key); const token = process.pid + "-" + randomUUID(); const ownerPath = lockPath + ".owner-" + token; - const ownerText = JSON.stringify({ pid: process.pid, token, createdAt: Date.now() }); + const processIdentity = await cachedProcessStartIdentity(process.pid); + const ownerText = JSON.stringify({ + pid: process.pid, processIdentity, token, createdAt: Date.now(), + }); await writeFile(ownerPath, ownerText, { flag: "wx", mode: 0o600 }); try { while (true) { @@ -632,7 +839,6 @@ async function acquireWorkspaceFileLock(key, cancel = null) { // filesystem lock extends the same canonical-worktree mutex across independent // stdio server processes, so two MCP clients cannot interleave workers. const workspaceLocks = new Map(); -const quarantinedWorkspaces = new Set(); async function withWorkspaceLock(key, fn, { cancel = null, onCancelled = null } = {}) { const prev = workspaceLocks.get(key) ?? Promise.resolve(); const prevDone = prev.catch(() => {}); @@ -736,8 +942,16 @@ async function delegateTask(rawArgs, cancel) { throw new Error("task must be a non-empty string"); } const workspacePath = await validateWorkspace(rawArgs.workspacePath); - await requireGitRepo(workspacePath); - const worktreeRoot = await gitWorktreeRoot(workspacePath); + let worktreeRoot = ""; + try { + await requireGitRepo(workspacePath, cancel); + worktreeRoot = await gitWorktreeRoot(workspacePath, cancel); + } catch (error) { + if (error instanceof OperationCancelledError) { + return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); + } + throw error; + } const lockKey = workspaceLockKey(worktreeRoot); const timeoutMs = Number.isInteger(rawArgs.timeoutMs) ? Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, rawArgs.timeoutMs)) @@ -745,13 +959,16 @@ async function delegateTask(rawArgs, cancel) { return await withWorkspaceLock(lockKey, async () => { const deadline = Date.now() + timeoutMs; - if (quarantinedWorkspaces.has(lockKey)) { + const sharedQuarantine = await readWorkspaceQuarantine(lockKey); + if (sharedQuarantine) { return { ok: false, - error: "this workspace is quarantined because a previous worker process tree could not be confirmed terminated; restart the bridge after checking for leftover processes", + error: "this workspace is quarantined because a previous worker process tree could not be confirmed terminated; inspect leftover processes, then remove the reported quarantine file deliberately", 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, experimental: Boolean(spec.experimental), }; } @@ -846,14 +1063,22 @@ async function delegateTask(rawArgs, cancel) { }, }); if (cancel?.controller === workerController) cancel.controller = null; - if (!result.treeTerminated) quarantinedWorkspaces.add(lockKey); + let quarantinePath = ""; + if (!result.treeTerminated) { + quarantinePath = await markWorkspaceQuarantined(lockKey, { + backend, + workspacePath, + worktreeRoot, + terminationError: result.terminationError, + }); + } let after = null; let commits = null; let postRunDeadlineExceeded = false; if (result.treeTerminated) { try { after = await gitSnapshot(worktreeRoot, { cancel, deadline }); - commits = await committedDelta(worktreeRoot, before.head, after.head, { cancel, deadline }); + commits = await committedDelta(worktreeRoot, before, after, { cancel, deadline }); } catch (error) { if (error instanceof OperationCancelledError) { // The worker is already stopped; report cancellation without a @@ -869,7 +1094,7 @@ async function delegateTask(rawArgs, cancel) { } let error = ""; if (!result.treeTerminated) { - error = "backend process tree could not be confirmed terminated; the workspace is quarantined until the bridge restarts"; + error = "backend process tree could not be confirmed terminated; the shared workspace quarantine remains until an operator checks for leftovers and removes quarantinePath"; } else if (cancel && cancel.cancelled) { error = "delegation cancelled by client; post-run snapshot may be unavailable"; } else if (result.timedOut || postRunDeadlineExceeded) { @@ -893,8 +1118,11 @@ async function delegateTask(rawArgs, cancel) { orphanedProcesses: result.orphanedProcesses, treeTerminated: result.treeTerminated, terminationError: result.terminationError, + quarantinePath, 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, @@ -922,6 +1150,12 @@ function textResult(header, obj) { 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"), "~~~"); + } lines.push("", "## commits made by the worker", "", "~~~text", obj.commits.log || "(none)", "~~~"); lines.push("", "## commit diff stat", "", "~~~text", obj.commits.diffStat || "(empty)", "~~~"); } @@ -1037,9 +1271,15 @@ async function handleMessage(message) { try { workspacePath = await validateWorkspace(args.workspacePath); if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath }); - await requireGitRepo(workspacePath); - if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath }); - worktreeRoot = await gitWorktreeRoot(workspacePath); + try { + await requireGitRepo(workspacePath, cancel); + worktreeRoot = await gitWorktreeRoot(workspacePath, cancel); + } 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 = workspaceLockKey(worktreeRoot); return await withWorkspaceLock(lockKey, async () => { 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 index f6c5fc8..99bdc4e 100644 --- a/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -24,7 +24,7 @@ inside the target git repository, and their results come back as a git diff for Delegations to the same workspace are serialized across bridge server processes, so parallel runs from separate MCP clients still queue instead of interleaving edits. 4. Review the returned result: the before and after git snapshots (status, diff stat, changed - files including staged and new files), the commits block when the worker committed, the + 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. 5. If the result is wrong, delegate a follow-up task. delegate_task results do not carry the @@ -56,8 +56,12 @@ inside the target git repository, and their results come back as a git diff for 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 - quarantines the worktree and blocks another worker until restart. The workspace may still contain - edits made before cancellation, so still review the returned snapshot. + writes a shared quarantine marker that blocks every bridge process. After checking for leftover + processes, an operator must deliberately remove the reported quarantinePath. The workspace may + still contain edits made before cancellation, so still review the returned snapshot. +- 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. diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs index 1019fdf..eb9ff8f 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { appendFileSync, writeFileSync } from "node:fs"; -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -17,14 +17,23 @@ async function delay(milliseconds) { await new Promise((resolve) => setTimeout(resolve, milliseconds)); } -if (spec.mode === "descendant") { +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.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"); - spawn(process.execPath, [ownPath, JSON.stringify({ + const descendant = spawn(process.execPath, [ownPath, JSON.stringify({ mode: "descendant", name: spec.name, eventFile: spec.eventFile, @@ -33,10 +42,14 @@ if (spec.mode === "descendant") { contents: spec.contents, })], { cwd: process.cwd(), + detached: spec.detachedDescendant === true, windowsHide: true, - stdio: "inherit", + stdio: spec.detachedDescendant === true ? "ignore" : "inherit", }); - await new Promise(() => {}); + 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); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index 6c9e0ae..ac6bc59 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { access, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { access, chmod, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { createInterface } from "node:readline"; @@ -16,9 +16,30 @@ const serverPath = path.join(pluginRoot, "server.mjs"); const fakeBackendPath = path.join(testsRoot, "fake-backend.mjs"); const requestKey = (id) => typeof id + ":" + String(id); +function currentUserLockRoot() { + 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 scope = createHash("sha256").update(identity).digest("hex").slice(0, 20); + return path.join(os.tmpdir(), "minimax-cli-agent-bridge-locks-" + scope); +} + +function workspaceStatePaths(canonicalRoot) { + const normalized = path.normalize(canonicalRoot); + const key = "git-worktree:" + (process.platform === "win32" ? normalized.toLowerCase() : normalized); + const digest = createHash("sha256").update(key).digest("hex"); + const root = currentUserLockRoot(); + return { + root, + lockPath: path.join(root, digest + ".lock"), + quarantinePath: path.join(root, digest + ".quarantine"), + }; +} + class McpClient { - constructor(configPath) { - this.child = execServer(configPath); + constructor(configPath, extraEnv = {}) { + this.child = execServer(configPath, extraEnv); this.pending = new Map(); this.stderr = ""; this.nextId = 1; @@ -84,9 +105,9 @@ class McpClient { } } -function execServer(configPath) { +function execServer(configPath, extraEnv = {}) { return spawn(process.execPath, [serverPath], { - env: { ...process.env, CLI_AGENT_BRIDGE_BACKENDS: configPath }, + env: { ...process.env, ...extraEnv, CLI_AGENT_BRIDGE_BACKENDS: configPath }, windowsHide: true, stdio: ["pipe", "pipe", "pipe"], }); @@ -216,11 +237,7 @@ test("canonical worktree locking serializes independent server processes", async test("a workspace lock left by a dead server process is reclaimed", async (context) => { const { workspace, client } = await makeHarness(context); const canonicalRoot = await realpath(workspace); - const normalized = path.normalize(canonicalRoot); - const key = "git-worktree:" + (process.platform === "win32" ? normalized.toLowerCase() : normalized); - const digest = createHash("sha256").update(key).digest("hex"); - const lockRoot = path.join(os.tmpdir(), "minimax-cli-agent-bridge-locks"); - const lockPath = path.join(lockRoot, digest + ".lock"); + const { root: lockRoot, lockPath } = workspaceStatePaths(canonicalRoot); await mkdir(lockRoot, { recursive: true }); context.after(() => rm(lockPath, { force: true })); await writeFile(lockPath, JSON.stringify({ @@ -236,6 +253,74 @@ test("a workspace lock left by a dead server process is reclaimed", async (conte await assert.rejects(access(lockPath), /ENOENT/u); }); +test("a stale lock is reclaimed when its PID was reused by another process", async (context) => { + const { workspace, client } = await makeHarness(context); + const { root, lockPath } = workspaceStatePaths(await realpath(workspace)); + await mkdir(root, { recursive: true }); + context.after(() => rm(lockPath, { force: true })); + await writeFile(lockPath, JSON.stringify({ + pid: process.pid, + processIdentity: "not-the-current-process-start", + token: "reused-pid-fixture", + createdAt: Date.now() - 60_000, + })); + const response = await client.request("tools/call", taskArguments(workspace, { + name: "after-reused-pid", writeFile: "reused-pid-reclaimed.txt", + })); + assert.equal(response.result.structuredContent.ok, true); +}); + +test("concurrent stale-lock reclaimers still serialize across processes", async (context) => { + const { tempRoot, workspace, configPath, client } = await makeHarness(context); + const secondClient = new McpClient(configPath); + try { + await secondClient.initialize(); + const { root, lockPath } = workspaceStatePaths(await realpath(workspace)); + await mkdir(root, { recursive: true }); + context.after(() => rm(lockPath, { force: true })); + await writeFile(lockPath, JSON.stringify({ + pid: 99_999_999, + processIdentity: "dead-owner", + token: "concurrent-reclaim-fixture", + createdAt: Date.now() - 60_000, + })); + const eventFile = path.join(tempRoot, "reclaim-race-events.jsonl"); + const first = client.request("tools/call", taskArguments(workspace, { + name: "reclaimer-one", eventFile, delayMs: 500, + }, { allowDirty: true }), 121); + const second = secondClient.request("tools/call", taskArguments(workspace, { + name: "reclaimer-two", eventFile, delayMs: 500, + }, { allowDirty: true }), 122); + const responses = await Promise.all([first, second]); + assert.ok(responses.every((response) => response.result.structuredContent.ok)); + const sequence = (await events(eventFile)).map((item) => item.event); + assert.deepEqual(sequence, ["start", "end", "start", "end"]); + } finally { + await secondClient.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 { root, quarantinePath } = workspaceStatePaths(await realpath(workspace)); + await mkdir(root, { recursive: true }); + context.after(() => rm(quarantinePath, { 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("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"); @@ -267,6 +352,39 @@ test("a request cancelled while queued never starts its backend", async (context 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 terminates descendants before returning", async (context) => { const { tempRoot, workspace, client } = await makeHarness(context); const eventFile = path.join(tempRoot, "events.jsonl"); @@ -293,6 +411,31 @@ test("cancellation terminates descendants before returning", async (context) => 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("timeout terminates descendants before releasing the request", async (context) => { const { tempRoot, workspace, client } = await makeHarness(context); const eventFile = path.join(tempRoot, "events.jsonl"); @@ -331,6 +474,26 @@ test("workspace status and delegation support an unborn HEAD", async (context) = 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(out.error)); + 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"); @@ -358,6 +521,17 @@ test("changedFiles preserves unusual names and scans from the worktree root", as assert.equal(status.result.structuredContent.worktreeRoot, await realpath(workspace)); }); +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"); From 13d4570eebfb24dffca7301ae6c000103998eecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 12:41:23 +0800 Subject: [PATCH 15/40] fix(cli-agent-bridge): use CAS workspace leases --- plugins/Hylouis233/cli-agent-bridge/README.md | 32 +- .../Hylouis233/cli-agent-bridge/server.mjs | 453 +++++++++++------- .../skills/cli-agent-bridge/SKILL.md | 10 +- .../cli-agent-bridge/test/server.test.mjs | 8 + .../cli-agent-bridge/tests/server.test.mjs | 243 +++++++--- .../tests/workspace-lock.test.mjs | 269 +++++++++++ .../cli-agent-bridge/workspace-lock.mjs | 417 ++++++++++++++++ 7 files changed, 1178 insertions(+), 254 deletions(-) create mode 100644 plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs create mode 100644 plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index bfe5ba1..4c10230 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -119,20 +119,31 @@ you already obtained a valid ID from that backend outside this Plugin. - Delegations and status snapshots targeting the same canonical Git worktree are serialized even when callers name a subdirectory, different path casing, or symlink, and even when separate MCP clients launched separate bridge server processes. Independent comparison runs still require - separate clean worktrees. The cross-process lock lives in a current-user-scoped directory under - the OS temporary directory. Dead-owner recovery compares both PID and process-start identity; - an atomic reclaim claim prevents two waiters from deleting a newly acquired lock. + separate clean worktrees. The cross-process lock is an owner blob referenced by an atomic Git-ref + compare-and-swap. A stale idle lock is reclaimed only when its same-host owner is positively + confirmed dead; malformed, foreign-host, starting, running, or uncertain records fail closed. + A crashed bridge cannot reconstruct descendants that escaped into another POSIX session from the + recorded worker PID alone, so inspect leftover processes and clear those hidden refs manually. +- Locking leaves the worktree and index unchanged, but it requires writable Git object/ref metadata: + each acquisition writes an owner blob and temporarily updates a hidden ref. Repository + reference-transaction hooks can observe or reject those updates, and released owner blobs remain + unreachable until normal Git garbage collection. For that reason 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. A lightweight ancestry monitor records descendants that create a new POSIX session/process group so cancellation still terminates them. If termination cannot be confirmed, the bridge writes a shared quarantine marker and every bridge process refuses further delegation until an operator checks for leftovers and deliberately removes the reported quarantinePath. + A retained Git-ref lease may also require deliberate removal after that process check; Windows + cannot safely reclaim a stale lease that recorded a running worker because descendant liveness + cannot be proven. The quarantine marker itself lives in a current-user-scoped OS temporary + directory. On Linux, zombie-only tracked trees count as terminated; zombies cannot edit the workspace and may otherwise persist when container PID 1 does not reap them. - 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 starts after the workspace lock is acquired and covers - preflight Git checks, the worker, and post-run snapshots. Safe process-tree termination can +- 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. Any bounded Git capture @@ -149,13 +160,14 @@ 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, atomic -stale-lock/PID-reuse recovery, shared quarantine markers, queued and discovery-phase cancellation, -cancel/timeout process-tree termination, escaped POSIX descendants and zombie-only Linux groups, -unusual Git pathnames, JSON-RPC id typing, unborn HEAD and non-HEAD ref changes, capture truncation, -and Codex prompt delimiters on Windows and POSIX. +They cover the full MCP flow plus in-process and cross-process canonical worktree locking, stale +owner compare-and-swap, live-owner non-steal, shared quarantine markers, queued and discovery-phase +cancellation, overall deadlines, cancel/timeout process-tree termination, escaped POSIX descendants +and zombie-only Linux groups, unusual Git pathnames, JSON-RPC id typing, unborn HEAD and non-HEAD +ref changes, capture truncation, and Codex prompt delimiters on Windows and POSIX. ## License diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 6be788c..25c7ab1 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -14,6 +14,12 @@ import { fileURLToPath } from "node:url"; import path from "node:path"; import { 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"; @@ -23,11 +29,14 @@ const MIN_TIMEOUT_MS = 5_000; const MAX_TIMEOUT_MS = 3_600_000; const VERSION_CHECK_TIMEOUT_MS = 15_000; const GIT_TIMEOUT_MS = 30_000; -const KILL_GRACE_MS = 10_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 RAW_TAIL_CHARS = 60_000; -const WORKSPACE_LOCK_RETRY_MS = 50; - function currentUserLockScope() { let identity; try { @@ -101,7 +110,7 @@ const TOOLS = [ name: "workspace_status", title: "Workspace Git Status", description: - "Return git status, diff stat, and changed files for a workspace before delegating work. Read-only.", + "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, @@ -114,7 +123,7 @@ const TOOLS = [ }, required: ["workspacePath"], }, - annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, }, { name: "delegate_task", @@ -155,7 +164,7 @@ const TOOLS = [ minimum: MIN_TIMEOUT_MS, maximum: MAX_TIMEOUT_MS, default: DEFAULT_TIMEOUT_MS, - description: "Overall deadline in milliseconds after the workspace lock is acquired. Covers 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.", + 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"], @@ -409,18 +418,18 @@ async function validateWorkspace(workspacePath) { return resolved; } -async function requireGitRepo(workspacePath, cancel = null) { +async function requireGitRepo(workspacePath, options = {}) { const result = await runGitCommand(["rev-parse", "--is-inside-work-tree"], { - cwd: workspacePath, cancel, + cwd: workspacePath, timeoutMs: 15_000, ...options, }); if (result.exitCode !== 0 || result.stdout.trim() !== "true") { throw new Error("workspacePath is not a git repository: " + workspacePath); } } -async function gitWorktreeRoot(workspacePath, cancel = null) { +async function gitWorktreeRoot(workspacePath, options = {}) { const result = await runGitCommand(["rev-parse", "--show-toplevel"], { - cwd: workspacePath, cancel, + cwd: workspacePath, ...options, }); const failure = snapshotFailure("git rev-parse --show-toplevel", result); if (failure || !result.stdout.trim()) { @@ -450,7 +459,13 @@ function snapshotFailure(label, result) { class OperationCancelledError extends Error {} class DeadlineExceededError extends Error {} -async function runGitCommand(args, { cwd, cancel = null, deadline = null, stdinText } = {}) { +async function runGitCommand(args, { + cwd, + cancel = null, + deadline = null, + stdinText, + timeoutMs = GIT_TIMEOUT_MS, +} = {}) { if (cancel?.cancelled) throw new OperationCancelledError("operation cancelled by client"); const remaining = deadline === null ? GIT_TIMEOUT_MS : deadline - Date.now(); if (remaining <= 0) throw new DeadlineExceededError("delegation deadline exceeded"); @@ -458,7 +473,7 @@ async function runGitCommand(args, { cwd, cancel = null, deadline = null, stdinT const result = await runCommand("git", args, { cwd, stdinText, - timeoutMs: Math.max(1, Math.min(GIT_TIMEOUT_MS, remaining)), + timeoutMs: Math.max(1, Math.min(timeoutMs, remaining)), killGraceMs: 1_000, shouldCancel: () => Boolean(cancel?.cancelled), onChild: (current) => { @@ -521,7 +536,10 @@ async function gitSnapshot(worktreeRoot, options = {}) { for (const line of String(out.refs ?? "").split(/\r?\n/u)) { if (!line) continue; const separator = line.indexOf("\t"); - if (separator > 0) refs[line.slice(0, separator)] = line.slice(separator + 1); + if (separator <= 0) continue; + const ref = line.slice(0, separator); + if (ref.startsWith(WORKSPACE_LOCK_REF_PREFIX)) continue; + refs[ref] = line.slice(separator + 1); } return { statusShort: String(out.status ?? "").trim(), @@ -618,11 +636,6 @@ async function listBackends() { return entries; } -function workspaceFileLockPath(key) { - const digest = createHash("sha256").update(key).digest("hex"); - return path.join(WORKSPACE_LOCK_ROOT, digest + ".lock"); -} - function workspaceQuarantinePath(key) { const digest = createHash("sha256").update(key).digest("hex"); return path.join(WORKSPACE_LOCK_ROOT, digest + ".quarantine"); @@ -661,27 +674,6 @@ async function markWorkspaceQuarantined(key, details) { return quarantinePath; } -async function processIsAlive(pid) { - if (!Number.isInteger(pid) || pid <= 0) return false; - if (process.platform === "linux") { - try { - const raw = await readFile("/proc/" + String(pid) + "/stat", "utf8"); - const close = raw.lastIndexOf(")"); - const state = close < 0 ? "" : raw.slice(close + 1).trim().split(/\s+/u)[0]; - if (state === "Z" || state === "X" || state === "x") return false; - } catch (error) { - if (error.code === "ENOENT") return false; - // Fall through to kill(0) when procfs is unavailable or restricted. - } - } - try { - process.kill(pid, 0); - return true; - } catch (error) { - return error.code === "EPERM"; - } -} - let linuxBootIdPromise = null; const processIdentityCache = new Map(); async function processStartIdentity(pid) { @@ -730,126 +722,36 @@ async function cachedProcessStartIdentity(pid) { return value; } -async function lockOwnerMatchesLiveProcess(owner) { - const ownerPid = Number(owner.pid); - const currentIdentity = await cachedProcessStartIdentity(ownerPid); - if (currentIdentity) { - // Old lock records without an identity fail safe while their PID is live. - return !owner.processIdentity || owner.processIdentity === currentIdentity; - } - return await processIsAlive(ownerPid); -} - -async function reclaimDeadWorkspaceFileLock(lockPath) { - let observed; - try { - observed = await readFile(lockPath, "utf8"); - } catch (error) { - if (error.code === "ENOENT") return true; - throw error; - } - let owner; - try { - owner = JSON.parse(observed); - } catch { - // Lock records are linked into place only after a complete metadata file - // has been written, so malformed content is not safe to reclaim blindly. - return false; - } - if (await lockOwnerMatchesLiveProcess(owner)) return false; - const claimPath = lockPath + ".reclaim"; - try { - // Creating this hard link is an atomic single-reclaimer claim on the exact - // lock inode. Other contenders cannot authorize a concurrent unlink. - await link(lockPath, claimPath); - } catch (error) { - if (error.code === "ENOENT") return true; - if (error.code === "EEXIST") return false; - throw error; - } - try { - const [claimed, current, claimStat, lockStat] = await Promise.all([ - readFile(claimPath, "utf8"), - readFile(lockPath, "utf8"), - stat(claimPath), - stat(lockPath), - ]); - if (claimed !== observed || current !== observed || - claimStat.dev !== lockStat.dev || claimStat.ino !== lockStat.ino) return false; - const latestOwner = JSON.parse(current); - if (await lockOwnerMatchesLiveProcess(latestOwner)) return false; - await unlink(lockPath); - return true; - } catch (error) { - if (error.code === "ENOENT") return true; - throw error; - } finally { - try { await unlink(claimPath); } catch (error) { if (error.code !== "ENOENT") throw error; } - } -} - -async function waitForWorkspaceLockRetry(cancel) { - const retry = new Promise((resolve) => setTimeout(() => resolve(true), WORKSPACE_LOCK_RETRY_MS)); - return cancel - ? await Promise.race([retry, cancel.promise.then(() => false)]) - : await retry; -} - -async function acquireWorkspaceFileLock(key, cancel = null) { - await mkdir(WORKSPACE_LOCK_ROOT, { recursive: true, mode: 0o700 }); - const lockPath = workspaceFileLockPath(key); - const token = process.pid + "-" + randomUUID(); - const ownerPath = lockPath + ".owner-" + token; - const processIdentity = await cachedProcessStartIdentity(process.pid); - const ownerText = JSON.stringify({ - pid: process.pid, processIdentity, token, createdAt: Date.now(), - }); - await writeFile(ownerPath, ownerText, { flag: "wx", mode: 0o600 }); - try { - while (true) { - if (cancel?.cancelled) return null; - try { - // Hard-link creation is an atomic create-if-absent operation on both - // NTFS and POSIX filesystems. The linked metadata is already complete. - await link(ownerPath, lockPath); - let released = false; - return { - async release() { - if (released) return; - released = true; - try { - if (await readFile(lockPath, "utf8") === ownerText) await unlink(lockPath); - } catch (error) { - if (error.code !== "ENOENT") throw error; - } - }, - }; - } catch (error) { - if (error.code !== "EEXIST") throw error; - } - if (await reclaimDeadWorkspaceFileLock(lockPath)) continue; - if (!await waitForWorkspaceLockRetry(cancel)) return null; - } - } finally { - try { await unlink(ownerPath); } catch (error) { if (error.code !== "ENOENT") throw error; } - } -} - -// The in-memory queue preserves FIFO order within this server. The atomic -// filesystem lock extends the same canonical-worktree mutex across independent -// stdio server processes, so two MCP clients cannot interleave workers. +// 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(); -async function withWorkspaceLock(key, fn, { cancel = null, onCancelled = null } = {}) { +const quarantinedWorkspaces = new Set(); +async function withWorkspaceLock(key, worktreeRoot, fn, { + cancel = null, + deadline = null, + onCancelled = null, + onDeadline = null, + isUnavailable = null, + onUnavailable = 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); - const acquired = cancel - ? await Promise.race([prevDone.then(() => true), cancel.promise.then(() => false)]) - : (await prevDone, true); - if (!acquired) { + 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 @@ -857,35 +759,70 @@ async function withWorkspaceLock(key, fn, { cancel = null, onCancelled = null } void next.finally(() => { if (workspaceLocks.get(key) === next) workspaceLocks.delete(key); }); - return typeof onCancelled === "function" ? onCancelled() : undefined; + if (localResult === "cancelled") { + return typeof onCancelled === "function" ? onCancelled() : undefined; + } + return typeof onDeadline === "function" ? onDeadline() : undefined; } - let fileLock = null; + let lease = null; try { - fileLock = await acquireWorkspaceFileLock(key, cancel); - if (!fileLock || cancel?.cancelled) { - if (fileLock) await fileLock.release(); - fileLock = null; - return typeof onCancelled === "function" ? onCancelled() : undefined; + if (typeof isUnavailable === "function" && await isUnavailable()) { + return typeof onUnavailable === "function" ? onUnavailable() : undefined; + } + try { + lease = await acquireGitWorkspaceLock({ cwd: worktreeRoot, key, cancel, deadline }); + } catch (error) { + if (error instanceof WorkspaceLockCancelledError) { + return typeof onCancelled === "function" ? onCancelled() : undefined; + } + if (error instanceof WorkspaceLockDeadlineError) { + return typeof onDeadline === "function" ? onDeadline() : undefined; + } + throw error; } - return await fn(); + return await fn(lease); } finally { - if (fileLock) await fileLock.release(); - release(); - if (workspaceLocks.get(key) === next) workspaceLocks.delete(key); + try { + if (lease) { + try { + await lease.release(); + } catch (error) { + // Register the exact leftover OID before releasing the local FIFO + // gate. This permits only this server's next local holder to repair + // a failed delete, including starting/running owner records. + lease.allowLocalRecovery(); + 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"); }, }; @@ -912,6 +849,46 @@ function cancelledDelegation({ backend, workspacePath, worktreeRoot, spec, befor }; } +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 process tree could not be confirmed terminated; inspect leftover processes, then remove the reported quarantine file deliberately", + 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, @@ -928,6 +905,24 @@ function cancelledWorkspaceStatus(id, { workspacePath = "", worktreeRoot = "" } }); } +function quarantinedWorkspaceStatus(id, { workspacePath = "", worktreeRoot = "" } = {}, sharedQuarantine = null) { + const out = { + ok: false, + error: "workspace status is unavailable because an earlier worker process tree could not be confirmed terminated", + 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) { const backends = await loadBackends(); if (!rawArgs || typeof rawArgs.backend !== "string" || !rawArgs.backend.trim()) { @@ -941,36 +936,45 @@ async function delegateTask(rawArgs, cancel) { if (typeof rawArgs.task !== "string" || !rawArgs.task.trim()) { throw new Error("task must be a non-empty string"); } - const workspacePath = await validateWorkspace(rawArgs.workspacePath); + const timeoutMs = Number.isInteger(rawArgs.timeoutMs) + ? Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, rawArgs.timeoutMs)) + : DEFAULT_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; + let workspacePath = ""; let worktreeRoot = ""; try { - await requireGitRepo(workspacePath, cancel); - worktreeRoot = await gitWorktreeRoot(workspacePath, cancel); + if (cancel?.cancelled) { + return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); + } + workspacePath = await validateWorkspace(rawArgs.workspacePath); + if (Date.now() >= deadline) throw new DeadlineExceededError("delegation deadline exceeded"); + await requireGitRepo(workspacePath, { cancel, deadline }); + worktreeRoot = await gitWorktreeRoot(workspacePath, { 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 identifying the Git worktree; the worker never started", + }); + } throw error; } const lockKey = workspaceLockKey(worktreeRoot); - const timeoutMs = Number.isInteger(rawArgs.timeoutMs) - ? Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, rawArgs.timeoutMs)) - : DEFAULT_TIMEOUT_MS; - - return await withWorkspaceLock(lockKey, async () => { - const deadline = Date.now() + timeoutMs; + const existingQuarantine = await readWorkspaceQuarantine(lockKey); + if (quarantinedWorkspaces.has(lockKey) || existingQuarantine) { + return quarantinedDelegation({ backend, workspacePath, worktreeRoot, spec }, existingQuarantine); + } + let observedQuarantine = null; + return await withWorkspaceLock(lockKey, worktreeRoot, async (workspaceLease) => { const sharedQuarantine = await readWorkspaceQuarantine(lockKey); - if (sharedQuarantine) { - return { - ok: false, - error: "this workspace is quarantined because a previous worker process tree could not be confirmed terminated; inspect leftover processes, then remove the reported quarantine file deliberately", - 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, - experimental: Boolean(spec.experimental), - }; + if (quarantinedWorkspaces.has(lockKey) || sharedQuarantine) { + return quarantinedDelegation({ backend, workspacePath, worktreeRoot, spec }, sharedQuarantine); } if (cancel && cancel.cancelled) { return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); @@ -1035,6 +1039,7 @@ async function delegateTask(rawArgs, cancel) { return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec, before }); } + await workspaceLease.markWorkerStarting(); const remaining = deadline - Date.now(); if (remaining <= 0) { return { @@ -1052,6 +1057,25 @@ async function delegateTask(rawArgs, cancel) { 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(); const result = await runCommand(spec.command, args, { cwd: workspacePath, timeoutMs: remaining, @@ -1060,17 +1084,44 @@ async function delegateTask(rawArgs, cancel) { onChild: (controller) => { workerController = controller; if (cancel) cancel.controller = controller; + if (ownershipLostError) void recordOwnershipLoss(ownershipLostError); + workerLockUpdate = workspaceLease.markWorkerRunning(controller.child.pid).catch(recordOwnershipLoss); }, }); + // Keep the controller live while runCommand is still inspecting or + // terminating escaped descendants after the leader closes. Once the full + // command result settles, clear it before any further await so a late lease + // notification cannot signal a reused PID/process-group identifier. + workerFinished = true; if (cancel?.controller === workerController) cancel.controller = null; + workerController = null; + await workerLockUpdate; let quarantinePath = ""; if (!result.treeTerminated) { + quarantinedWorkspaces.add(lockKey); + workspaceLease.retain(); quarantinePath = await markWorkspaceQuarantined(lockKey, { backend, workspacePath, worktreeRoot, terminationError: result.terminationError, }); + // The shared marker is now authoritative and removable by an operator; + // retain the local fallback only when writing that marker failed. + quarantinedWorkspaces.delete(lockKey); + } else if (!ownershipLostError) { + try { + await workspaceLease.markWorkerIdle(); + } catch (error) { + await recordOwnershipLoss(error); + } + } + if (ownershipLostError) { + await ownershipTermination; + if (ownershipTerminationError && ownershipLostError.cause === undefined) { + ownershipLostError.cause = ownershipTerminationError; + } + throw ownershipLostError; } let after = null; let commits = null; @@ -1130,7 +1181,16 @@ async function delegateTask(rawArgs, cancel) { }; }, { cancel, + deadline, onCancelled: () => cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }), + onDeadline: () => lockDeadlineDelegation({ backend, workspacePath, worktreeRoot, spec }), + isUnavailable: async () => { + observedQuarantine = await readWorkspaceQuarantine(lockKey); + return quarantinedWorkspaces.has(lockKey) || Boolean(observedQuarantine); + }, + onUnavailable: () => quarantinedDelegation( + { backend, workspacePath, worktreeRoot, spec }, observedQuarantine, + ), }); } @@ -1272,8 +1332,9 @@ async function handleMessage(message) { workspacePath = await validateWorkspace(args.workspacePath); if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath }); try { - await requireGitRepo(workspacePath, cancel); - worktreeRoot = await gitWorktreeRoot(workspacePath, cancel); + await requireGitRepo(workspacePath, { cancel }); + if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath }); + worktreeRoot = await gitWorktreeRoot(workspacePath, { cancel }); } catch (error) { if (error instanceof OperationCancelledError) { return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); @@ -1282,7 +1343,20 @@ async function handleMessage(message) { } if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); const lockKey = workspaceLockKey(worktreeRoot); - return await withWorkspaceLock(lockKey, async () => { + const existingQuarantine = await readWorkspaceQuarantine(lockKey); + if (quarantinedWorkspaces.has(lockKey) || existingQuarantine) { + return quarantinedWorkspaceStatus( + message.id, { workspacePath, worktreeRoot }, existingQuarantine, + ); + } + let observedQuarantine = null; + return await withWorkspaceLock(lockKey, worktreeRoot, async () => { + const sharedQuarantine = await readWorkspaceQuarantine(lockKey); + if (quarantinedWorkspaces.has(lockKey) || sharedQuarantine) { + return quarantinedWorkspaceStatus( + message.id, { workspacePath, worktreeRoot }, sharedQuarantine, + ); + } try { const git = await gitSnapshot(worktreeRoot, { cancel }); if (cancel.cancelled) { @@ -1301,6 +1375,13 @@ async function handleMessage(message) { }, { cancel, onCancelled: () => cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }), + isUnavailable: async () => { + observedQuarantine = await readWorkspaceQuarantine(lockKey); + return quarantinedWorkspaces.has(lockKey) || Boolean(observedQuarantine); + }, + onUnavailable: () => quarantinedWorkspaceStatus( + message.id, { workspacePath, worktreeRoot }, observedQuarantine, + ), }); } finally { finishRequest(); 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 index 99bdc4e..4010251 100644 --- a/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -50,8 +50,8 @@ inside the target git repository, and their results come back as a git diff for created are listed under changed files even though they do not appear in git diff --stat. - Run independent or comparison workers in separate clean Git worktrees at the same starting commit. A second run in one checkout inherits the first run's edits and is not independent. -- Timeouts: the default is 20 minutes; adjust timeoutMs for very large tasks. The deadline starts - after lock acquisition and covers preflight Git checks, the worker, and post-run snapshots. A +- 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 @@ -64,6 +64,12 @@ inside the target git repository, and their results come back as a git diff for 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 worktree files, but cross-process serialization temporarily writes + a hidden Git lock ref and owner blob. Git metadata must be writable, and repository + reference-transaction hooks may observe or reject the lock update. +- 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 that hidden ref. ## Notes diff --git a/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs index 150059d..059cc1e 100644 --- a/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs @@ -95,6 +95,9 @@ test("tools/list exposes the three bridge tools", async () => { 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); }); }); @@ -156,6 +159,11 @@ test("delegate_task returns before and after snapshots and committed deltas", as 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/"))); }); }); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index ac6bc59..5a02ce2 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { access, chmod, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { access, chmod, copyFile, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { createInterface } from "node:readline"; @@ -9,6 +9,8 @@ import test from "node:test"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; +import { workspaceLockRef } from "../workspace-lock.mjs"; + const execFileAsync = promisify(execFile); const testsRoot = path.dirname(fileURLToPath(import.meta.url)); const pluginRoot = path.resolve(testsRoot, ".."); @@ -32,7 +34,6 @@ function workspaceStatePaths(canonicalRoot) { const root = currentUserLockRoot(); return { root, - lockPath: path.join(root, digest + ".lock"), quarantinePath: path.join(root, digest + ".quarantine"), }; } @@ -234,72 +235,202 @@ test("canonical worktree locking serializes independent server processes", async } }); -test("a workspace lock left by a dead server process is reclaimed", async (context) => { - const { workspace, client } = await makeHarness(context); - const canonicalRoot = await realpath(workspace); - const { root: lockRoot, lockPath } = workspaceStatePaths(canonicalRoot); - await mkdir(lockRoot, { recursive: true }); - context.after(() => rm(lockPath, { force: true })); - await writeFile(lockPath, JSON.stringify({ - pid: 99_999_999, - token: "dead-server-fixture", - createdAt: Date.now() - 60_000, - })); - - const response = await client.request("tools/call", taskArguments(workspace, { - name: "after-stale-lock", writeFile: "reclaimed.txt", - })); - assert.equal(response.result.structuredContent.ok, true); - await assert.rejects(access(lockPath), /ENOENT/u); -}); +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 }); -test("a stale lock is reclaimed when its PID was reused by another process", async (context) => { - const { workspace, client } = await makeHarness(context); - const { root, lockPath } = workspaceStatePaths(await realpath(workspace)); - await mkdir(root, { recursive: true }); - context.after(() => rm(lockPath, { force: true })); - await writeFile(lockPath, JSON.stringify({ - pid: process.pid, - processIdentity: "not-the-current-process-start", - token: "reused-pid-fixture", - createdAt: Date.now() - 60_000, - })); - const response = await client.request("tools/call", taskArguments(workspace, { - name: "after-reused-pid", writeFile: "reused-pid-reclaimed.txt", - })); - assert.equal(response.result.structuredContent.ok, true); + 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("concurrent stale-lock reclaimers still serialize across processes", async (context) => { +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 { root, lockPath } = workspaceStatePaths(await realpath(workspace)); - await mkdir(root, { recursive: true }); - context.after(() => rm(lockPath, { force: true })); - await writeFile(lockPath, JSON.stringify({ - pid: 99_999_999, - processIdentity: "dead-owner", - token: "concurrent-reclaim-fixture", - createdAt: Date.now() - 60_000, - })); - const eventFile = path.join(tempRoot, "reclaim-race-events.jsonl"); - const first = client.request("tools/call", taskArguments(workspace, { - name: "reclaimer-one", eventFile, delayMs: 500, - }, { allowDirty: true }), 121); - const second = secondClient.request("tools/call", taskArguments(workspace, { - name: "reclaimer-two", eventFile, delayMs: 500, - }, { allowDirty: true }), 122); - const responses = await Promise.all([first, second]); - assert.ok(responses.every((response) => response.result.structuredContent.ok)); - const sequence = (await events(eventFile)).map((item) => item.event); - assert.deepEqual(sequence, ["start", "end", "start", "end"]); + 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 canonicalRoot = await realpath(workspace); + const normalized = path.normalize(canonicalRoot); + const key = "git-worktree:" + (process.platform === "win32" ? normalized.toLowerCase() : normalized); + const ref = workspaceLockRef(key); + 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: workspace }); + const { stdout: blob } = await execFileAsync("git", ["cat-file", "blob", oid.trim()], { cwd: workspace }); + 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: workspace }); + const replacementOid = replacementOidText.trim(); + const replacedAt = Date.now(); + await execFileAsync("git", ["update-ref", ref, replacementOid], { cwd: workspace }); + context.after(async () => { + try { await execFileAsync("git", ["update-ref", "-d", ref, replacementOid], { cwd: workspace }); } 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: workspace }); + + 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", { + skip: process.platform !== "win32", +}, 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 quarantinePath = null; + try { + await client.initialize(); + const canonicalRoot = await realpath(workspace); + ({ quarantinePath } = workspaceStatePaths(canonicalRoot)); + context.after(() => rm(quarantinePath, { force: true })); + const normalized = path.normalize(canonicalRoot); + const key = "git-worktree:" + normalized.toLowerCase(); + ref = workspaceLockRef(key); + 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: workspace }); + const { stdout: blob } = await execFileAsync("git", ["cat-file", "blob", oid.trim()], { cwd: workspace }); + 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: workspace }); + 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: workspace }); + + 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, { force: true }); + await execFileAsync("git", ["update-ref", "-d", ref, replacementOid], { cwd: workspace }); + 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) { + try { await execFileAsync("git", ["update-ref", "-d", ref, replacementOid], { cwd: workspace }); } 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); 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..ae8feb8 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs @@ -0,0 +1,269 @@ +import assert from "node:assert/strict"; +import { execFile, execFileSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { mkdir, mkdtemp, 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, + workspaceLockRef, +} from "../workspace-lock.mjs"; + +const execFileAsync = promisify(execFile); + +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("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 local 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); + first.lease.allowLocalRecovery(); + 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("post-CAS cancellation remains recoverable when its compensating delete fails", 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"; + let cancellationChecks = 0; + const cancel = { + get cancelled() { + cancellationChecks += 1; + if (cancellationChecks === 7) writeFileSync(blocker, "intentional compensating-delete failure\n"); + return cancellationChecks >= 7; + }, + }; + + await assert.rejects( + tryAcquireGitWorkspaceLock({ cwd: repo, key, cancel, heartbeatMs: 60_000 }), + /cancelled/iu, + ); + assert.equal(cancellationChecks, 7, "cancellation must be observed only after the CAS commits"); + await rm(blocker, { force: true }); + const recovered = await 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(); +}); 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..c20dea7 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs @@ -0,0 +1,417 @@ +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import os from "node:os"; + +export const WORKSPACE_LOCK_REF_PREFIX = "refs/cli-agent-bridge/workspace-locks/"; +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; +// A failed release may leave this process's exact owner blob installed. The +// server registers that OID while its in-process FIFO gate is still held, so +// only the next local holder may replace it after the failed request unwinds. +const locallyAbandonedRefs = new Map(); + +export class WorkspaceLockCancelledError extends Error {} +export class WorkspaceLockDeadlineError extends Error {} + +export function localHostIdentity() { + return `${process.platform}:${os.hostname().toLowerCase()}`; +} + +export function workspaceLockRef(key) { + return WORKSPACE_LOCK_REF_PREFIX + createHash("sha256").update(key).digest("hex"); +} + +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"); + } +} + +async function runGit(cwd, args, { + stdinText, + cancel = null, + deadline = null, + returnOnTimeout = false, +} = {}) { + checkInterrupted(cancel, deadline); + const remaining = deadline === null ? GIT_TIMEOUT_MS : deadline - Date.now(); + const timeoutMs = Math.max(1, Math.min(GIT_TIMEOUT_MS, remaining)); + const result = await new Promise((resolve) => { + const child = spawn("git", args, { + cwd, + 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); + }); + 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); + 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 canReclaim(owner, { + now, + staleMs, + hostIdentity, + processProbe, +}) { + if (!owner || owner.version !== 1 || owner.hostIdentity !== hostIdentity) return false; + if (!Number.isFinite(owner.heartbeatAt) || now - owner.heartbeatAt < staleMs) return false; + if (await processProbe(owner.ownerPid) !== "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; +} + +function makeOwner({ hostIdentity, ownerPid, now }) { + return { + version: 1, + token: randomUUID(), + hostIdentity, + ownerPid, + workerState: "idle", + workerPid: null, + acquiredAt: now, + heartbeatAt: now, + }; +} + +function createLease({ cwd, ref, oid, owner, heartbeatMs }) { + const localRefKey = cwd + "\0" + ref; + 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 resolveLost; + const lost = new Promise((resolve) => { resolveLost = resolve; }); + let heartbeatPending = false; + + const rememberLoss = (error) => { + if (lostError) return; + lostError = error; + resolveLost(error); + }; + + const queueUpdate = (change) => { + updateChain = updateChain.then(async () => { + if (stopped || lostError) return; + const nextOwner = { ...currentOwner, ...change, heartbeatAt: Date.now() }; + const nextOid = await writeOwnerBlob(cwd, nextOwner); + if (!await compareAndSwap(cwd, ref, nextOid, currentOid)) { + throw new Error("workspace lock ownership changed during heartbeat"); + } + currentOwner = nextOwner; + currentOid = nextOid; + }).catch((error) => { + rememberLoss(error); + }); + return updateChain.then(() => { + if (lostError) throw lostError; + }); + }; + + const timer = setInterval(() => { + if (stopped || heartbeatPending || lostError) return; + heartbeatPending = true; + void queueUpdate({}).catch(() => {}).finally(() => { heartbeatPending = false; }); + }, heartbeatMs); + timer.unref?.(); + + return { + get ref() { return ref; }, + lost, + async assertOwned() { + await updateChain; + if (lostError) throw lostError; + }, + async markWorkerStarting() { + await queueUpdate({ workerState: "starting", workerPid: null }); + }, + async markWorkerRunning(pid) { + if (!Number.isInteger(pid) || pid <= 0) throw new Error("worker pid is unavailable"); + await queueUpdate({ workerState: "running", workerPid: pid }); + }, + async markWorkerIdle() { + await queueUpdate({ workerState: "idle", workerPid: null }); + }, + retain() { + retained = true; + }, + allowLocalRecovery() { + if (stopped && !retained && !released) locallyAbandonedRefs.set(localRefKey, currentOid); + }, + async release() { + if (released) return; + if (releasePromise) return await releasePromise; + stopped = true; + clearInterval(timer); + releasePromise = (async () => { + await updateChain; + if (retained) { + if (lostError) throw lostError; + released = true; + return; + } + let deleted = false; + let deleteError = null; + for (let attempt = 0; attempt < RELEASE_ATTEMPTS; attempt += 1) { + try { + deleted = await compareAndDelete(cwd, ref, currentOid); + deleteError = null; + break; + } catch (error) { + deleteError = error; + if (attempt + 1 < RELEASE_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, RELEASE_RETRY_MS)); + } + } + } + if (deleteError) throw deleteError; + released = true; + 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, + now = Date.now(), + processProbe = probeProcess, +} = {}) { + checkInterrupted(cancel, deadline); + const ref = workspaceLockRef(key); + const localRefKey = cwd + "\0" + ref; + const current = await readCurrentOwner(cwd, ref, { cancel, deadline }); + const abandonedOid = locallyAbandonedRefs.get(localRefKey); + const locallyAbandoned = Boolean(current && abandonedOid === current.oid); + if (!current || (abandonedOid && !locallyAbandoned)) locallyAbandonedRefs.delete(localRefKey); + if (current && !locallyAbandoned && !await canReclaim(current.owner, { + now, staleMs, hostIdentity, processProbe, + })) { + return { acquired: false, reason: "held" }; + } + const owner = makeOwner({ hostIdentity, ownerPid, now }); + const newOid = await writeOwnerBlob(cwd, owner, { cancel, deadline }); + let acquired = false; + if (!current) { + const zeroOid = "0".repeat(newOid.length); + checkInterrupted(cancel, deadline); + acquired = await compareAndSwap(cwd, ref, newOid, zeroOid); + } else { + checkInterrupted(cancel, deadline); + acquired = await compareAndSwap(cwd, ref, newOid, current.oid); + } + if (!acquired) return { acquired: false, reason: "contended" }; + locallyAbandonedRefs.delete(localRefKey); + const lease = createLease({ cwd, ref, oid: newOid, owner, heartbeatMs }); + try { + checkInterrupted(cancel, deadline); + } catch (error) { + try { + await lease.release(); + } catch (releaseError) { + // Acquisition committed before cancellation/deadline was observed. If + // the compensating delete fails, preserve the exact OID for the next + // local FIFO holder instead of orphaning an unrecoverable live-owner ref. + lease.allowLocalRecovery(); + 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); + } +} From 4a6fb483f91d47bebc85d62f0b075b633c1a7d8c Mon Sep 17 00:00:00 2001 From: Hylouis233 Date: Sun, 16 Aug 2026 15:38:50 +0800 Subject: [PATCH 16/40] fix(cli-agent-bridge): close audit findings on leases, trees, and attribution - disclose repository concurrency: linked worktrees share refs, so snapshots detect other active leases plus completed runs' history records and mark attribution as potentially overlapping (repositoryConcurrency) - attribute only genuinely new commits: ranges exclude all pre-delegation refs, so a branch checkout is reported as a HEAD move with no new commits, and non-commit refs (blob tags) are reported without failing the run - verify process identity before signaling: Windows tracks creation times, POSIX groups are signaled only while the original leader identity matches, and reused PIDs are dropped from the tracked tree - make lease state updates interruptible by the request cancellation/deadline and resync the interrupted release by owner token - move quarantined leases to a recoverable state: removing the quarantine marker authorizes the next delegation to reclaim - catch process-tree inspection failures in the close handler (fail closed instead of an unhandled rejection) and slow the Linux /proc monitor to 250ms - tests: 55 total (checkout attribution, blob refs, concurrency disclosure, lease interruption, quarantined reclaim, PID-reuse identity) --- plugins/Hylouis233/cli-agent-bridge/README.md | 45 ++- .../cli-agent-bridge/process-tree.mjs | 104 +++++-- .../Hylouis233/cli-agent-bridge/server.mjs | 290 +++++++++++++++--- .../skills/cli-agent-bridge/SKILL.md | 6 +- .../cli-agent-bridge/tests/fake-backend.mjs | 15 + .../tests/process-tree.test.mjs | 69 +++++ .../cli-agent-bridge/tests/server.test.mjs | 63 ++++ .../tests/workspace-lock.test.mjs | 80 +++++ .../cli-agent-bridge/workspace-lock.mjs | 100 +++++- 9 files changed, 677 insertions(+), 95 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 4c10230..49fc044 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -124,6 +124,13 @@ you already obtained a valid ID from that backend outside this Plugin. confirmed dead; malformed, foreign-host, starting, running, or uncertain records fail closed. A crashed bridge cannot reconstruct descendants that escaped into another POSIX session from the recorded worker PID alone, so inspect leftover processes and clear those hidden refs manually. +- Worktrees of the same repository serialize independently (each has its own lock), but Git refs + are shared by the whole repository. When another delegation was active in the same repository + during a run - detected from live leases plus the run-history record each completed delegation + leaves under a hidden `.history` ref - the result sets `repositoryConcurrency: true` and the + commits section is labelled as attributed rather than exact, because ref movements may include + the other worker's commits. Perfect attribution across shared refs would require serializing + the entire repository, which would break parallel independent-worktree runs. - Locking leaves the worktree and index unchanged, but it requires writable Git object/ref metadata: each acquisition writes an owner blob and temporarily updates a hidden ref. Repository reference-transaction hooks can observe or reject those updates, and released owner blobs remain @@ -131,13 +138,18 @@ you already obtained a valid ID from that backend outside this Plugin. 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. A lightweight ancestry monitor records descendants that create a new POSIX - session/process group so cancellation still terminates them. If termination cannot be confirmed, - the bridge writes a shared quarantine marker and every bridge process refuses further delegation - until an operator checks for leftovers and deliberately removes the reported quarantinePath. - A retained Git-ref lease may also require deliberate removal after that process check; Windows - cannot safely reclaim a stale lease that recorded a running worker because descendant liveness - cannot be proven. The quarantine marker itself lives in a current-user-scoped OS temporary - directory. + session/process group so cancellation still terminates them; tracked PIDs are matched against + their recorded start identity (process start time on POSIX, creation time on Windows) so a + reused PID is never signaled, and a POSIX process group is only signaled while its original + leader identity still matches. If termination cannot be confirmed, the bridge writes a shared + quarantine marker, moves its lease into the recoverable `quarantined` state, and every bridge + process refuses further delegation until an operator checks for leftovers and deliberately + removes the reported quarantinePath - removing that marker also authorizes the next delegation + to reclaim the quarantined lease. If the bridge crashes mid-run, a lease recording a running + worker still cannot be reclaimed automatically (descendant liveness cannot be proven); delete + the hidden lock ref recorded in the quarantine marker (or run `git update-ref -d` on the ref + under `refs/cli-agent-bridge/workspace-locks/`) after checking for leftover processes. The + quarantine marker itself lives in a current-user-scoped OS temporary directory. On Linux, zombie-only tracked trees count as terminated; zombies cannot edit the workspace and may otherwise persist when container PID 1 does not reap them. - Cancelling a workspace_status request interrupts its queued lock wait or Git snapshot and returns @@ -146,8 +158,12 @@ you already obtained a valid ID from that backend outside this Plugin. 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. Any bounded Git capture - that truncates is rejected as an unreliable snapshot; backend output truncation is disclosed. + 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. 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 @@ -164,10 +180,13 @@ 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, shared quarantine markers, queued and discovery-phase -cancellation, overall deadlines, cancel/timeout process-tree termination, escaped POSIX descendants -and zombie-only Linux groups, unusual Git pathnames, JSON-RPC id typing, unborn HEAD and non-HEAD -ref changes, capture truncation, and Codex prompt delimiters on Windows and POSIX. +owner compare-and-swap, live-owner non-steal, quarantined-lease recovery after the operator +removes the marker, interruptible lease state updates, shared quarantine markers, queued and +discovery-phase cancellation, overall deadlines, cancel/timeout process-tree termination, escaped +POSIX descendants and zombie-only Linux groups, PID-reuse identity checks before signaling, +unusual Git pathnames, JSON-RPC id typing, unborn HEAD and non-HEAD ref changes, checkout-only +HEAD moves, non-commit refs, repository-concurrency disclosure between linked worktrees, capture +truncation, and Codex prompt delimiters on Windows and POSIX. ## License diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs index d72ea8f..9d8e2e9 100644 --- a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs @@ -36,13 +36,17 @@ function runUtility(command, args, timeoutMs = 5_000) { }); } -async function windowsProcessTreePids(rootPid, knownPids = new Set()) { +export async function windowsProcessTreePids( + rootPid, + treeState = { knownPids: new Set(), knownStarts: new Map() }, + { runUtility: run = runUtility } = {}, +) { const script = [ "$ErrorActionPreference='Stop'", - "$items=@(Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId)", + "$items=@(Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,@{n='CreationTicks';e={$_.CreationDate.ToUniversalTime().Ticks}})", "$items | ConvertTo-Json -Compress", ].join("; "); - const result = await runUtility("powershell.exe", [ + const result = await run("powershell.exe", [ "-NoProfile", "-NonInteractive", "-Command", script, ]); if (result.exitCode !== 0) { @@ -54,11 +58,30 @@ async function windowsProcessTreePids(rootPid, knownPids = new Set()) { const processes = (Array.isArray(parsed) ? parsed : [parsed]).map((item) => ({ pid: Number(item.ProcessId), parentPid: Number(item.ParentProcessId), + startIdentity: String(item.CreationTicks ?? ""), })); + treeState.knownStarts ??= new Map(); + const liveIdentities = new Map(processes.map((item) => [item.pid, item.startIdentity])); + // A known PID whose creation time changed is an unrelated process that reused + // the ID; it must leave the tracked tree before anything is signaled. + for (const pid of [...treeState.knownPids]) { + const expected = treeState.knownStarts.get(pid); + const observed = liveIdentities.get(pid); + if (expected && observed && expected !== observed) treeState.knownPids.delete(pid); + } const livePids = new Set(processes.map((item) => item.pid)); - const descendants = new Set([...knownPids].filter((pid) => livePids.has(pid))); - if (livePids.has(rootPid)) descendants.add(rootPid); - const parents = new Set([rootPid, ...knownPids]); + const descendants = new Set([...treeState.knownPids].filter((pid) => livePids.has(pid))); + if (livePids.has(rootPid)) { + const rootExpected = treeState.knownStarts.get(rootPid); + const rootObserved = liveIdentities.get(rootPid); + // Record the root identity on first observation; afterwards a mismatch + // means the backend PID already exited and was reused. + if (!rootExpected || !rootObserved || rootExpected === rootObserved) { + descendants.add(rootPid); + if (rootObserved) treeState.knownStarts.set(rootPid, rootObserved); + } + } + const parents = new Set([rootPid, ...treeState.knownPids]); let changed = true; while (changed) { changed = false; @@ -70,7 +93,11 @@ async function windowsProcessTreePids(rootPid, knownPids = new Set()) { } } } - for (const pid of descendants) knownPids.add(pid); + for (const pid of descendants) { + const identity = liveIdentities.get(pid); + if (identity && !treeState.knownStarts.has(pid)) treeState.knownStarts.set(pid, identity); + } + for (const pid of descendants) treeState.knownPids.add(pid); return [...descendants]; } @@ -160,13 +187,19 @@ 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.knownPids); + await windowsProcessTreePids(child.pid, treeState, options); return null; } - const processes = await posixProcessSnapshot(options); + const processes = await (options.posixProcessSnapshot ?? 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); + if (leader?.startIdentity && !treeState.knownStarts.has(child.pid)) { + treeState.knownStarts.set(child.pid, leader.startIdentity); + } const matchesKnownIdentity = (item) => { const expected = treeState.knownStarts.get(item.pid); return !expected || !item.startIdentity || expected === item.startIdentity; @@ -201,14 +234,18 @@ export async function isProcessTreeAlive(child, treeState, { } = {}) { if (!Number.isInteger(child.pid)) return false; if (platform === "win32") { - return (await windowsProcessTreePids(child.pid, treeState.knownPids)).length > 0; + return (await windowsProcessTreePids(child.pid, treeState)).length > 0; } const processes = await refreshProcessTree(child, treeState, { platform, procRoot, fsOps }); if (processes !== null) { const knownStarts = treeState.knownStarts ?? new Map(); + const leaderStart = knownStarts.get(child.pid); const trackedLive = processes.some((item) => { if (!isLiveState(item.state)) return false; - if (item.processGroupId === child.pid) return true; + if (item.processGroupId === child.pid) { + // A reused PID leading an unrelated group must not count as our tree. + return !leaderStart || !item.startIdentity || item.startIdentity === leaderStart; + } if (!treeState.knownPids.has(item.pid)) return false; const expected = knownStarts.get(item.pid); return !expected || !item.startIdentity || expected === item.startIdentity; @@ -231,34 +268,53 @@ export async function isProcessTreeAlive(child, treeState, { return true; } -export async function signalProcessTree(child, signal, treeState) { +export async function signalProcessTree(child, signal, treeState, { + platform = process.platform, + posixProcessSnapshot: snapshot = posixProcessSnapshot, + runUtility: run = runUtility, + killOne = (pid, sig) => process.kill(pid, sig), + killGroup = (pgid, sig) => process.kill(-pgid, sig), +} = {}) { if (!Number.isInteger(child.pid)) return; - if (process.platform === "win32") { + if (platform === "win32") { // Windows has no portable SIGTERM equivalent for arbitrary console CLIs; // /T /F is required to terminate the complete tree deterministically. - await runUtility("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"]); + await run("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"]); // If the root exited first, taskkill cannot traverse it. Win32_Process // normally retains the old parent PID, while known PIDs survive re-parenting. - const remaining = await windowsProcessTreePids(child.pid, treeState.knownPids); + // windowsProcessTreePids drops any known PID whose creation identity changed, + // so reused PIDs are never signaled. + const remaining = await windowsProcessTreePids(child.pid, treeState, { runUtility: run }); for (const pid of remaining.reverse()) { - await runUtility("taskkill.exe", ["/PID", String(pid), "/T", "/F"]); + await run("taskkill.exe", ["/PID", String(pid), "/T", "/F"]); } return; } - await refreshProcessTree(child, treeState); - try { - process.kill(-child.pid, signal); - } catch (error) { - if (error.code !== "ESRCH") throw error; - } - const processes = await posixProcessSnapshot(); + await refreshProcessTree(child, treeState, { platform, posixProcessSnapshot: snapshot }); + 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. + const leader = byPid.get(child.pid); + const leaderStart = treeState.knownStarts?.get(child.pid); + const groupIsOriginal = Boolean(leader) && + leader.processGroupId === child.pid && + isLiveState(leader.state) && + (!leaderStart || !leader.startIdentity || leader.startIdentity === leaderStart); + if (groupIsOriginal) { + 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); if (item && expected && item.startIdentity && expected !== item.startIdentity) continue; - try { process.kill(pid, signal); } + try { killOne(pid, signal); } catch (error) { if (error.code !== "ESRCH") throw error; } } } diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 25c7ab1..11f67af 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -330,11 +330,17 @@ async function runCommand(command, args, options = {}) { if (typeof options.onChild === "function" && Number.isInteger(child.pid)) { options.onChild({ child, terminate }); } - if (manageProcessTree && process.platform !== "win32") { + if (manageProcessTree) { + // Capture the root's start identity immediately so termination can later + // detect a reused PID. Windows performs this one-shot inspection only; + // POSIX keeps polling to track descendants that escape the process group. + // The 250 ms interval trades discovery latency against scanning every + // process under /proc (or forking `ps`) for the whole delegation. void refreshTree(); - const refreshIntervalMs = process.platform === "linux" ? 25 : 250; - treeRefreshTimer = setInterval(() => { void refreshTree(); }, refreshIntervalMs); - treeRefreshTimer.unref?.(); + if (process.platform !== "win32") { + treeRefreshTimer = setInterval(() => { void refreshTree(); }, 250); + treeRefreshTimer.unref?.(); + } } if (child.stdin) child.stdin.end(options.stdinText); @@ -370,9 +376,23 @@ async function runCommand(command, args, options = {}) { if (settled) return; exitCode = code; if (terminationPromise) return; - if (manageProcessTree && await isProcessTreeAlive(child, treeState)) { - await terminate("orphaned"); - return; + if (manageProcessTree) { + let stillAlive = false; + try { + stillAlive = await isProcessTreeAlive(child, treeState); + } catch (error) { + // 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; + settle(); + return; + } + if (stillAlive) { + await terminate("orphaned"); + return; + } } settle(); }); @@ -489,7 +509,13 @@ async function runGitCommand(args, { return result; } +// A lease owner counts as concurrently active while its heartbeat is fresh +// and its worker has not finished; linked worktrees share one ref store, so +// another worktree's delegation is visible here and can interleave commits. +const CONCURRENT_LEASE_STALE_MS = 30_000; + async function gitSnapshot(worktreeRoot, options = {}) { + const ownLockRef = typeof options.ownLockRef === "string" ? options.ownLockRef : null; const jobs = [ ["git status --short", "status", ["status", "--short"]], ["git diff --stat", "diffStat", ["diff", "--stat"]], @@ -533,23 +559,81 @@ async function gitSnapshot(worktreeRoot, options = {}) { .map((s, i) => (i === 0 ? s : s.split(/\r?\n/).map((l) => "staged: " + l).join("\n"))) .join("\n"); const refs = {}; + const lockRefs = []; 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); - if (ref.startsWith(WORKSPACE_LOCK_REF_PREFIX)) continue; + if (ref.startsWith(WORKSPACE_LOCK_REF_PREFIX)) { + if (ref !== ownLockRef) lockRefs.push(line.slice(separator + 1)); + continue; + } refs[ref] = line.slice(separator + 1); } + // 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 signals: leases that are active right now, and the + // persistent run-history records completed delegations leave behind, whose + // [acquiredAt, endedAt] window is checked against this snapshot's window. + const windowStart = Number.isFinite(options.concurrencyWindowStart) + ? options.concurrencyWindowStart + : Number.POSITIVE_INFINITY; + let concurrentDelegations = 0; + for (const oid of lockRefs) { + const blob = await runGitCommand(["cat-file", "blob", oid], { cwd: worktreeRoot, ...options }); + if (blob.exitCode !== 0) continue; // unreadable owner blob: ignore for disclosure + try { + const record = JSON.parse(blob.stdout); + if (Number.isFinite(record?.endedAt)) { + const acquiredAt = Number.isFinite(record.acquiredAt) ? record.acquiredAt : record.endedAt; + if (acquiredAt <= Date.now() && record.endedAt >= windowStart) { + concurrentDelegations += 1; + } + continue; + } + const active = record && + (record.workerState === "starting" || record.workerState === "running") && + Number.isFinite(record.heartbeatAt) && + Date.now() - record.heartbeatAt < CONCURRENT_LEASE_STALE_MS; + if (active) concurrentDelegations += 1; + } catch { /* malformed owner blob: ignore for disclosure */ } + } return { statusShort: String(out.status ?? "").trim(), diffStat, changedFiles, head: String(out.head ?? "").trim(), refs, + concurrentDelegations, }; } +// 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; +} + 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) => { @@ -575,43 +659,86 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { return emptyTreeId; } - const ranges = new Map(); - async function addRange(label, beforeOid, afterOid) { - if (!afterOid || beforeOid === afterOid) return; - const base = beforeOid || before.head || await emptyTree(); - const range = base + ".." + afterOid; - const labels = ranges.get(range) ?? []; - labels.push(label); - ranges.set(range, labels); + const cache = new Map(); + // 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 ?? {}), + ...refsChanged.map((change) => change.before), + ])) { + await addBaseline(oid); } - await addRange("HEAD", before.head, after.head); + + const targets = [{ label: "HEAD", beforeOid: before.head, afterOid: after.head }]; for (const change of refsChanged) { - await addRange(change.ref, change.before, change.after); + targets.push({ label: change.ref, beforeOid: change.before, afterOid: change.after }); } const logs = []; const stats = []; - for (const [range, labels] of ranges) { - const log = await runGitCommand(["log", "--oneline", range], { - cwd: worktreeRoot, ...options, - }); + let newCommitCount = 0; + for (const { label, beforeOid, afterOid } of targets) { + if (!afterOid || beforeOid === afterOid) continue; + 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. + logs.push(label + " -> " + afterOid + " (non-commit object; no commit log)"); + stats.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", "--oneline", target, "--stdin"] + : ["log", "--oneline", 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 = label === "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"; + logs.push(label + ": " + note); + stats.push(label + ": (no new commits)"); + continue; + } + newCommitCount += newCommits.split("\n").length; + const base = beforeOid || before.head || await emptyTree(); + const range = base + ".." + target; const diff = await runGitCommand(["diff", "--stat", range], { cwd: worktreeRoot, ...options, }); - const failures = [ - snapshotFailure("git log " + range, log), - snapshotFailure("git diff --stat " + range, diff), - ].filter(Boolean); - if (failures.length > 0) throw new Error("committed delta unreliable: " + failures.join("; ")); - const heading = labels.join(", ") + " [" + range + "]"; - logs.push(heading + "\n" + (String(log.stdout ?? "").trim() || "(no new commits)")); - stats.push(heading + "\n" + (String(diff.stdout ?? "").trim() || "(empty)")); + const diffFailure = snapshotFailure("git diff --stat " + range, diff); + if (diffFailure) throw new Error("committed delta unreliable: " + diffFailure); + logs.push(label + " [" + range + "]\n" + newCommits); + stats.push(label + " [" + range + "]\n" + (String(diff.stdout ?? "").trim() || "(empty)")); } return { - range: [...ranges.keys()].join("\n"), + range: logs.length > 0 ? "attribution: new commits only (pre-existing history excluded)" : "", refsChanged, - log: logs.join("\n\n"), - diffStat: stats.join("\n\n"), + newCommitCount, + log: logs.join("\n\n") || "(no ref or HEAD movements)", + diffStat: stats.join("\n\n") || "(empty)", }; } @@ -654,6 +781,16 @@ async function readWorkspaceQuarantine(key) { } } +// Quarantined leases become reclaimable through this check: the operator +// deliberately removed the shared marker after inspecting leftover processes. +async function quarantineFileAbsent(key) { + try { + return (await readWorkspaceQuarantine(key)) === null; + } catch { + return false; + } +} + async function markWorkspaceQuarantined(key, details) { await mkdir(WORKSPACE_LOCK_ROOT, { recursive: true, mode: 0o700 }); const quarantinePath = workspaceQuarantinePath(key); @@ -734,6 +871,7 @@ async function withWorkspaceLock(key, worktreeRoot, fn, { onDeadline = null, isUnavailable = null, onUnavailable = null, + operatorCleared = null, } = {}) { const prev = workspaceLocks.get(key) ?? Promise.resolve(); const prevDone = prev.catch(() => {}); @@ -770,7 +908,7 @@ async function withWorkspaceLock(key, worktreeRoot, fn, { return typeof onUnavailable === "function" ? onUnavailable() : undefined; } try { - lease = await acquireGitWorkspaceLock({ cwd: worktreeRoot, key, cancel, deadline }); + lease = await acquireGitWorkspaceLock({ cwd: worktreeRoot, key, cancel, deadline, operatorCleared }); } catch (error) { if (error instanceof WorkspaceLockCancelledError) { return typeof onCancelled === "function" ? onCancelled() : undefined; @@ -980,9 +1118,12 @@ async function delegateTask(rawArgs, cancel) { return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); } const allowDirty = rawArgs.allowDirty === true; + // Attribution window for concurrency disclosure: everything between the + // before-snapshot and the after-snapshot. + const attributionWindowStart = Date.now(); let before; try { - before = await gitSnapshot(worktreeRoot, { cancel, deadline }); + before = await gitSnapshot(worktreeRoot, { cancel, deadline, ownLockRef: workspaceLease.ref }); } catch (error) { if (error instanceof OperationCancelledError) { return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); @@ -1039,7 +1180,26 @@ async function delegateTask(rawArgs, cancel) { return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec, before }); } - await workspaceLease.markWorkerStarting(); + // 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; + } const remaining = deadline - Date.now(); if (remaining <= 0) { return { @@ -1085,7 +1245,16 @@ async function delegateTask(rawArgs, cancel) { workerController = controller; if (cancel) cancel.controller = controller; if (ownershipLostError) void recordOwnershipLoss(ownershipLostError); - workerLockUpdate = workspaceLease.markWorkerRunning(controller.child.pid).catch(recordOwnershipLoss); + 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); + }); }, }); // Keep the controller live while runCommand is still inspecting or @@ -1100,10 +1269,17 @@ async function delegateTask(rawArgs, cancel) { if (!result.treeTerminated) { quarantinedWorkspaces.add(lockKey); workspaceLease.retain(); + // Move the retained lease into the recoverable "quarantined" state. A + // running-state lease can never be reclaimed, which would leave the + // workspace locked forever even after the operator removes the marker. + try { + await workspaceLease.markWorkerQuarantined(); + } catch { /* lease lost or interrupted: the marker file below still gates recovery */ } quarantinePath = await markWorkspaceQuarantined(lockKey, { backend, workspacePath, worktreeRoot, + lockRef: workspaceLease.ref, terminationError: result.terminationError, }); // The shared marker is now authoritative and removable by an operator; @@ -1111,9 +1287,12 @@ async function delegateTask(rawArgs, cancel) { quarantinedWorkspaces.delete(lockKey); } else if (!ownershipLostError) { try { - await workspaceLease.markWorkerIdle(); + await workspaceLease.markWorkerIdle({ cancel, deadline }); } catch (error) { - await recordOwnershipLoss(error); + if (!(error instanceof WorkspaceLockCancelledError) && + !(error instanceof WorkspaceLockDeadlineError)) { + await recordOwnershipLoss(error); + } } } if (ownershipLostError) { @@ -1128,7 +1307,12 @@ async function delegateTask(rawArgs, cancel) { let postRunDeadlineExceeded = false; if (result.treeTerminated) { try { - after = await gitSnapshot(worktreeRoot, { cancel, deadline }); + after = await gitSnapshot(worktreeRoot, { + cancel, + deadline, + ownLockRef: workspaceLease.ref, + concurrencyWindowStart: attributionWindowStart, + }); commits = await committedDelta(worktreeRoot, before, after, { cancel, deadline }); } catch (error) { if (error instanceof OperationCancelledError) { @@ -1143,6 +1327,13 @@ async function delegateTask(rawArgs, cancel) { } } } + // 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 process tree could not be confirmed terminated; the shared workspace quarantine remains until an operator checks for leftovers and removes quarantinePath"; @@ -1170,6 +1361,7 @@ async function delegateTask(rawArgs, cancel) { 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), @@ -1184,6 +1376,7 @@ async function delegateTask(rawArgs, cancel) { deadline, onCancelled: () => cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }), onDeadline: () => lockDeadlineDelegation({ backend, workspacePath, worktreeRoot, spec }), + operatorCleared: () => quarantineFileAbsent(lockKey), isUnavailable: async () => { observedQuarantine = await readWorkspaceQuarantine(lockKey); return quarantinedWorkspaces.has(lockKey) || Boolean(observedQuarantine); @@ -1206,8 +1399,7 @@ function textResult(header, obj) { 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)", "~~~"); - }; - gitBlock("before", obj.gitBefore); + }; gitBlock("before", obj.gitBefore); gitBlock("after", obj.git); if (obj.commits) { if (obj.commits.refsChanged?.length) { @@ -1216,9 +1408,20 @@ function textResult(header, obj) { item.ref + " " + (item.before || "(absent)") + " -> " + (item.after || "(deleted)"), ).join("\n"), "~~~"); } - lines.push("", "## commits made by the worker", "", "~~~text", obj.commits.log || "(none)", "~~~"); + 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); @@ -1375,6 +1578,7 @@ async function handleMessage(message) { }, { cancel, onCancelled: () => cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }), + operatorCleared: () => quarantineFileAbsent(lockKey), isUnavailable: async () => { observedQuarantine = await readWorkspaceQuarantine(lockKey); return quarantinedWorkspaces.has(lockKey) || Boolean(observedQuarantine); 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 index 4010251..94cd08f 100644 --- a/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -27,6 +27,9 @@ inside the target git repository, and their results come back as a git diff for 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. + When repositoryConcurrency is true, another delegation was active in the same repository + during the run, so the commits block lists attributed commits that may overlap the other + worker - do not present them as this worker's 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 @@ -69,7 +72,8 @@ inside the target git repository, and their results come back as a git diff for reference-transaction hooks may observe or reject the lock update. - 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 that hidden ref. + bridge crash; inspect the process tree before deliberately clearing that hidden ref. A lease + moved to the quarantined state is reclaimable once the operator removes the quarantine marker. ## Notes diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs index eb9ff8f..c5ebc00 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs @@ -26,6 +26,21 @@ if (spec.branchRoundTrip) { 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.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.mode === "descendant") { event("descendant-start"); await delay(spec.delayMs ?? 1_000); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs index 9e47a1b..f47940c 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs @@ -7,7 +7,10 @@ import test from "node:test"; import { isProcessTreeAlive, linuxProcessGroupHasLiveMembers, + refreshProcessTree, + signalProcessTree, waitForProcessTreeExit, + windowsProcessTreePids, } from "../process-tree.mjs"; async function writeProcStat(root, pid, { state, group, command = "worker" }) { @@ -79,3 +82,69 @@ test("zombie-only groups count as exited only for the final post-SIGKILL wait", 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, 9002]), 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 }); + + 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("windows tree inspection drops known PIDs whose creation identity changed", async () => { + const treeState = { + knownPids: new Set([500, 501]), + knownStarts: new Map([[500, "ticks-1"], [501, "ticks-2"]]), + }; + 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: "ticks-REUSED" }, + { ProcessId: 501, ParentProcessId: 500, CreationTicks: "ticks-2" }, + { ProcessId: 502, ParentProcessId: 501, CreationTicks: "ticks-3" }, + ]), + 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), "ticks-1", "the original identity is retained for comparison"); +}); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index 5a02ce2..95d0e1d 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -758,6 +758,69 @@ test("closing MCP stdin terminates active worker descendants", async (context) = await assert.rejects(access(path.join(workspace, "shutdown-descendant-survived.txt")), /ENOENT/u); }); +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("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("parallel worktree delegations disclose repository concurrency", async (context) => { + const harness = await makeHarness(context); + const { tempRoot, workspace, configPath } = harness; + // A second independent worktree of the same repository: per-worktree locks + // are distinct while repository refs are shared. + const secondWorktree = path.join(tempRoot, "second-worktree"); + await execFileAsync("git", ["worktree", "add", secondWorktree], { cwd: workspace }); + + const eventFile = path.join(tempRoot, "parallel-events.log"); + const slowSpec = { name: "slow", delayMs: 3_000, eventFile }; + const fastClient = new McpClient(configPath); + await fastClient.initialize(); + context.after(async () => { await fastClient.close(); }); + + const slowPromise = harness.client.request("tools/call", taskArguments(workspace, slowSpec)); + await waitFor(async () => (await events(eventFile)).some((item) => item.event === "start")); + const overlapping = await fastClient.request("tools/call", taskArguments(secondWorktree, { + name: "fast-in-worktree", delayMs: 0, + })); + const overlappingOut = overlapping.result.structuredContent; + assert.equal(overlappingOut.ok, true, JSON.stringify(overlappingOut.error)); + assert.equal(overlappingOut.repositoryConcurrency, true, + "an overlapping delegation in a linked worktree must disclose concurrency"); + const slowOut = (await slowPromise).result.structuredContent; + assert.equal(slowOut.ok, true, JSON.stringify(slowOut.error)); + assert.equal(slowOut.repositoryConcurrency, true, + "the slow delegation observed the overlapping worker in its after snapshot"); +}); + test("PowerShell shim runner fails closed for a missing backend", { skip: process.platform !== "win32", }, async () => { diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs index ae8feb8..7da9ceb 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs @@ -12,6 +12,8 @@ import { localHostIdentity, tryAcquireGitWorkspaceLock, workspaceLockRef, + WorkspaceLockCancelledError, + WorkspaceLockDeadlineError, } from "../workspace-lock.mjs"; const execFileAsync = promisify(execFile); @@ -267,3 +269,81 @@ test("long lock waits unsubscribe cancellation listeners after every retry", asy 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("quarantined leases are reclaimable after the operator clears the marker", 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", + workerPid: 4242, + acquiredAt: now, + heartbeatAt: now, + }); + + const stillHeld = await tryAcquireGitWorkspaceLock({ + cwd: repo, key, now, staleMs: 60_000, processProbe: () => "alive", + operatorCleared: () => false, + }); + assert.deepEqual(stillHeld, { acquired: false, reason: "held" }); + + const reclaimed = await tryAcquireGitWorkspaceLock({ + cwd: repo, key, now, staleMs: 60_000, processProbe: () => "alive", + operatorCleared: () => true, + }); + assert.equal(reclaimed.acquired, true, "a removed quarantine marker authorizes takeover"); + await reclaimed.lease.release(); +}); + +test("a quarantined lease left by a crashed owner is reclaimable after the stale window", 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", + workerPid: 5353, + acquiredAt: now - 120_000, + heartbeatAt: now - 120_000, + }); + const result = await tryAcquireGitWorkspaceLock({ + cwd: repo, key, now, staleMs: 30_000, processProbe: () => "dead", + operatorCleared: () => false, + }); + assert.equal(result.acquired, true, "crash fallback: stale heartbeat plus dead owner"); + await result.lease.release(); +}); diff --git a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs index c20dea7..c7806ef 100644 --- a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; import os from "node:os"; export const WORKSPACE_LOCK_REF_PREFIX = "refs/cli-agent-bridge/workspace-locks/"; +const WORKSPACE_HISTORY_REF_SUFFIX = ".history"; const DEFAULT_STALE_MS = 30_000; const DEFAULT_HEARTBEAT_MS = 5_000; const DEFAULT_POLL_MS = 100; @@ -28,6 +29,33 @@ export function workspaceLockRef(key) { return WORKSPACE_LOCK_REF_PREFIX + createHash("sha256").update(key).digest("hex"); } +// A completed delegation leaves a persistent run-window record under this ref +// so later snapshots in the same repository can prove their attribution window +// overlapped another worker, even after that worker's lease ref was deleted. +export function workspaceHistoryRef(key) { + return workspaceLockRef(key) + WORKSPACE_HISTORY_REF_SUFFIX; +} + +async function writeRunHistory(cwd, lockRef, owner) { + try { + const record = { + version: 1, + acquiredAt: Number.isFinite(owner.acquiredAt) ? owner.acquiredAt : null, + endedAt: Date.now(), + hostIdentity: owner.hostIdentity, + }; + const oidResult = await runGit(cwd, ["hash-object", "-w", "--stdin"], { + stdinText: JSON.stringify(record) + "\n", + }); + const oid = oidResult.stdout.trim(); + if (oidResult.exitCode !== 0 || !/^[0-9a-f]{40,64}$/u.test(oid)) return; + await runGit(cwd, ["update-ref", "--no-deref", lockRef + WORKSPACE_HISTORY_REF_SUFFIX, oid]); + } catch { + // Best effort: a missing history record only weakens concurrency + // disclosure, never correctness of locking. + } +} + export function probeProcess(pid) { if (!Number.isInteger(pid) || pid <= 0) return "unknown"; if (process.platform === "linux") { @@ -200,9 +228,23 @@ async function canReclaim(owner, { staleMs, hostIdentity, processProbe, + operatorCleared = null, }) { if (!owner || owner.version !== 1 || owner.hostIdentity !== hostIdentity) return false; - if (!Number.isFinite(owner.heartbeatAt) || now - owner.heartbeatAt < staleMs) 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 once the quarantine + // marker is deliberately removed (or, failing that, the owner died and the + // heartbeat went stale after a crash). + if (owner.workerState === "quarantined") { + if (operatorCleared) { + try { + if (await operatorCleared()) return true; + } catch { /* treat a failed check as not cleared */ } + } + return now - owner.heartbeatAt >= staleMs && await processProbe(owner.ownerPid) === "dead"; + } + if (now - owner.heartbeatAt < staleMs) return false; if (await processProbe(owner.ownerPid) !== "dead") return false; if (owner.workerState === "idle" && owner.workerPid === null) return true; // Starting/running records always fail closed. The live bridge tracks @@ -226,6 +268,7 @@ function makeOwner({ hostIdentity, ownerPid, now }) { function createLease({ cwd, ref, oid, owner, heartbeatMs }) { const localRefKey = cwd + "\0" + ref; + const ownerToken = owner.token; let currentOid = oid; let currentOwner = owner; let updateChain = Promise.resolve(); @@ -234,6 +277,7 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { let releasePromise = null; let retained = false; let lostError = null; + let interruptedError = null; let resolveLost; const lost = new Promise((resolve) => { resolveLost = resolve; }); let heartbeatPending = false; @@ -243,27 +287,40 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { lostError = error; resolveLost(error); }; + const isInterruption = (error) => + error instanceof WorkspaceLockCancelledError || error instanceof WorkspaceLockDeadlineError; - const queueUpdate = (change) => { + // 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) return; + if (stopped || lostError || interruptedError) return; const nextOwner = { ...currentOwner, ...change, heartbeatAt: Date.now() }; - const nextOid = await writeOwnerBlob(cwd, nextOwner); - if (!await compareAndSwap(cwd, ref, nextOid, currentOid)) { + const nextOid = await writeOwnerBlob(cwd, nextOwner, interrupt); + if (!await compareAndSwap(cwd, ref, nextOid, currentOid, interrupt)) { throw new Error("workspace lock ownership changed during heartbeat"); } currentOwner = nextOwner; currentOid = nextOid; }).catch((error) => { + if (isInterruption(error)) { + interruptedError ??= error; + return; + } rememberLoss(error); }); return updateChain.then(() => { + if (interruptedError) throw interruptedError; if (lostError) throw lostError; }); }; const timer = setInterval(() => { - if (stopped || heartbeatPending || lostError) return; + if (stopped || heartbeatPending || lostError || interruptedError) return; heartbeatPending = true; void queueUpdate({}).catch(() => {}).finally(() => { heartbeatPending = false; }); }, heartbeatMs); @@ -275,16 +332,20 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { async assertOwned() { await updateChain; if (lostError) throw lostError; + if (interruptedError) throw interruptedError; }, - async markWorkerStarting() { - await queueUpdate({ workerState: "starting", workerPid: null }); + async markWorkerStarting(interrupt = {}) { + await queueUpdate({ workerState: "starting", workerPid: null }, interrupt); }, - async markWorkerRunning(pid) { + async markWorkerRunning(pid, interrupt = {}) { if (!Number.isInteger(pid) || pid <= 0) throw new Error("worker pid is unavailable"); - await queueUpdate({ workerState: "running", workerPid: pid }); + await queueUpdate({ workerState: "running", workerPid: pid }, interrupt); + }, + async markWorkerIdle(interrupt = {}) { + await queueUpdate({ workerState: "idle", workerPid: null }, interrupt); }, - async markWorkerIdle() { - await queueUpdate({ workerState: "idle", workerPid: null }); + async markWorkerQuarantined() { + await queueUpdate({ workerState: "quarantined" }); }, retain() { retained = true; @@ -298,12 +359,21 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { stopped = true; clearInterval(timer); releasePromise = (async () => { - await updateChain; + await updateChain.catch(() => {}); if (retained) { if (lostError) throw lostError; released = true; return; } + // An interrupted state update may have committed its CAS after the git + // process was killed, leaving currentOid stale. Resync by owner token + // so cleanup still deletes this process's own lease record. + if (interruptedError) { + try { + const observed = await readCurrentOwner(cwd, ref); + if (observed && observed.owner?.token === ownerToken) currentOid = observed.oid; + } catch { /* best effort; the delete below still uses the last known OID */ } + } let deleted = false; let deleteError = null; for (let attempt = 0; attempt < RELEASE_ATTEMPTS; attempt += 1) { @@ -320,6 +390,7 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { } if (deleteError) throw deleteError; released = true; + if (deleted) await writeRunHistory(cwd, ref, currentOwner); if (lostError) throw lostError; if (!deleted) throw new Error("workspace lock ownership changed before release"); })(); @@ -343,6 +414,7 @@ export async function tryAcquireGitWorkspaceLock({ ownerPid = process.pid, now = Date.now(), processProbe = probeProcess, + operatorCleared = null, } = {}) { checkInterrupted(cancel, deadline); const ref = workspaceLockRef(key); @@ -352,7 +424,7 @@ export async function tryAcquireGitWorkspaceLock({ const locallyAbandoned = Boolean(current && abandonedOid === current.oid); if (!current || (abandonedOid && !locallyAbandoned)) locallyAbandonedRefs.delete(localRefKey); if (current && !locallyAbandoned && !await canReclaim(current.owner, { - now, staleMs, hostIdentity, processProbe, + now, staleMs, hostIdentity, processProbe, operatorCleared, })) { return { acquired: false, reason: "held" }; } From 72a0fb2b33d293855008429d46fbdbb18ccdf593 Mon Sep 17 00:00:00 2001 From: Hylouis233 Date: Sun, 16 Aug 2026 16:04:33 +0800 Subject: [PATCH 17/40] fix(cli-agent-bridge): close latest review round - deduplicate HEAD and branch-ref targets so a commit on the checked-out branch is logged, diffed, and counted once with both labels - diff new refs from their merge-base with the pre-delegation state, so a branch forked from a divergent branch never attributes pre-existing differences to the worker - register list_backends in activeRequests and terminate a hanging version probe on cancellation, skipping the remaining probes - signal the process group when enumeration is unavailable (containment wins when identity cannot be verified) - strip only Git's line terminator from rev-parse --show-toplevel so a worktree root ending in whitespace canonicalizes correctly - tests: 60 total, all green locally --- plugins/Hylouis233/cli-agent-bridge/README.md | 12 ++- .../cli-agent-bridge/process-tree.mjs | 7 +- .../Hylouis233/cli-agent-bridge/server.mjs | 92 +++++++++++++++---- .../cli-agent-bridge/tests/fake-backend.mjs | 18 ++++ .../tests/process-tree.test.mjs | 18 ++++ .../cli-agent-bridge/tests/server.test.mjs | 91 ++++++++++++++++++ 6 files changed, 211 insertions(+), 27 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 49fc044..ca3527f 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -182,11 +182,13 @@ 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 the operator removes the marker, interruptible lease state updates, shared quarantine markers, queued and -discovery-phase cancellation, overall deadlines, cancel/timeout process-tree termination, escaped -POSIX descendants and zombie-only Linux groups, PID-reuse identity checks before signaling, -unusual Git pathnames, JSON-RPC id typing, unborn HEAD and non-HEAD ref changes, checkout-only -HEAD moves, non-commit refs, repository-concurrency disclosure between linked worktrees, capture -truncation, and Codex prompt delimiters on Windows and POSIX. +discovery-phase cancellation (including list_backends probes), overall deadlines, cancel/timeout +process-tree termination, escaped POSIX descendants and zombie-only Linux groups, PID-reuse +identity checks before signaling, unusual Git pathnames (including a trailing-space worktree +root), JSON-RPC id typing, 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, repository-concurrency disclosure between linked worktrees, +capture truncation, and Codex prompt delimiters on Windows and POSIX. ## License diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs index 9d8e2e9..a2734f9 100644 --- a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs @@ -295,14 +295,17 @@ export async function signalProcessTree(child, signal, treeState, { 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. + // 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 leader = byPid.get(child.pid); const leaderStart = treeState.knownStarts?.get(child.pid); const groupIsOriginal = Boolean(leader) && leader.processGroupId === child.pid && isLiveState(leader.state) && (!leaderStart || !leader.startIdentity || leader.startIdentity === leaderStart); - if (groupIsOriginal) { + if (groupIsOriginal || processes === null) { try { killGroup(child.pid, signal); } catch (error) { diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 11f67af..ac941c2 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -452,11 +452,14 @@ async function gitWorktreeRoot(workspacePath, options = {}) { cwd: workspacePath, ...options, }); const failure = snapshotFailure("git rev-parse --show-toplevel", result); - if (failure || !result.stdout.trim()) { + // 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 { - return await realpath(result.stdout.trim()); + return await realpath(output); } catch (error) { throw new Error("cannot canonicalize Git worktree root: " + error.message); } @@ -677,16 +680,32 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { await addBaseline(oid); } - const targets = [{ label: "HEAD", beforeOid: before.head, afterOid: after.head }]; + // 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) { - targets.push({ label: change.ref, beforeOid: change.before, afterOid: change.after }); + addTarget(change.ref, change.before, change.after); } const logs = []; const stats = []; let newCommitCount = 0; - for (const { label, beforeOid, afterOid } of targets) { + 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 @@ -714,7 +733,7 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { if (revListFailure) throw new Error("committed delta unreliable: " + revListFailure); const newCommits = String(revList.stdout ?? "").trim(); if (!newCommits) { - const note = label === "HEAD" && beforeOid + 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"; @@ -723,7 +742,24 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { continue; } newCommitCount += newCommits.split("\n").length; - const base = beforeOid || before.head || await emptyTree(); + // For a ref that did not exist before, diff from its best common ancestor + // with the pre-delegation state, not from the original HEAD: a new branch + // created from another divergent branch would otherwise attribute every + // pre-existing difference between those branches to the worker. + let base; + if (beforeOid) { + base = beforeOid; + } else if (baselineCommits.length > 0) { + const mergeBase = await runGitCommand( + ["merge-base", target, ...baselineCommits.slice(0, 256)], + { cwd: worktreeRoot, ...options }, + ); + base = mergeBase.exitCode === 0 && mergeBase.stdout.trim() + ? mergeBase.stdout.trim() + : await emptyTree(); + } else { + base = before.head || await emptyTree(); + } const range = base + ".." + target; const diff = await runGitCommand(["diff", "--stat", range], { cwd: worktreeRoot, ...options, @@ -742,12 +778,22 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { }; } -async function listBackends() { +async function listBackends(cancel = null) { const backends = await loadBackends(); 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) break; if (!spec || typeof spec.command !== "string") continue; - const check = await runCommand(spec.command, ["--version"], { timeoutMs: VERSION_CHECK_TIMEOUT_MS }); + const check = await runCommand(spec.command, ["--version"], { + timeoutMs: VERSION_CHECK_TIMEOUT_MS, + shouldCancel: () => Boolean(cancel?.cancelled), + onChild: (controller) => { + if (cancel) cancel.controller = controller; + }, + }); + if (cancel?.controller) cancel.controller = null; entries.push({ name, label: typeof spec.label === "string" ? spec.label : name, @@ -1513,18 +1559,24 @@ async function handleMessage(message) { 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 entries = await listBackends(); - 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); + 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 }, + }); + } finally { + finishRequest(); } - return jsonRpcResult(message.id, { - content: [{ type: "text", text: lines.join("\n") }], - structuredContent: { backends: entries }, - }); } if (params.name === "workspace_status") { const cancel = createCancellation(); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs index c5ebc00..ecadef0 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs @@ -31,6 +31,24 @@ if (spec.branchRoundTrip) { 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"); + 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"]); + 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"); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs index f47940c..b2a747d 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs @@ -148,3 +148,21 @@ test("windows tree inspection drops known PIDs whose creation identity changed", assert.ok(!treeState.knownPids.has(500), "the reused PID leaves the tracked set"); assert.equal(treeState.knownStarts.get(500), "ticks-1", "the original identity is retained for comparison"); }); + +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" }], + }); + 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"); +}); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index 95d0e1d..972171d 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -821,6 +821,76 @@ test("parallel worktree delegations disclose repository concurrency", async (con "the slow delegation observed the overlapping worker in its after snapshot"); }); + +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("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("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"); + await writeFile(hangScript, "setTimeout(() => {}, 60_000);\n"); + const configPath = path.join(tempRoot, "backends.json"); + await writeFile(configPath, JSON.stringify({ + backends: { + hang: { + label: "Hanging backend", + command: process.execPath, + buildArgs: [hangScript, ""], + 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 new Promise((resolve) => setTimeout(resolve, 200)); + 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.ok(Array.isArray(response.result.structuredContent.backends)); +}); + test("PowerShell shim runner fails closed for a missing backend", { skip: process.platform !== "win32", }, async () => { @@ -837,3 +907,24 @@ test("PowerShell shim runner fails closed for a missing backend", { assert.ok(failure, "missing backend must return a non-zero exit code"); assert.notEqual(failure.code, 0); }); + +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)); +}); From 34b96900b8b8f6fb9e990166e9abcfe9a239b526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 16:51:34 +0800 Subject: [PATCH 18/40] fix(cli-agent-bridge): isolate shared refs and process scans --- plugins/Hylouis233/cli-agent-bridge/README.md | 33 +-- .../cli-agent-bridge/process-tree.mjs | 260 +++++++++++++----- .../Hylouis233/cli-agent-bridge/server.mjs | 57 ++-- .../skills/cli-agent-bridge/SKILL.md | 18 +- .../tests/process-tree.test.mjs | 43 ++- .../cli-agent-bridge/tests/server.test.mjs | 85 +++--- 6 files changed, 347 insertions(+), 149 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index ca3527f..44b832c 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -36,15 +36,13 @@ 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 worktrees first. +then compare the two diffs. Create two independent clones first. ``` -Expected result: the orchestrator creates two git worktrees (`git worktree add ../ws-claude`, -`git worktree add ../ws-kimi`), 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. Independent -comparison runs need separate worktrees: the first run leaves its checkout dirty, so a -same-workspace second run would be rejected by the allowDirty=false guard (same-checkout runs -are serialized into a queue, which suits follow-up work, not parallel comparisons). +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 @@ -116,21 +114,18 @@ you already obtained a valid ID from that backend outside this Plugin. 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 canonical Git worktree are serialized even - when callers name a subdirectory, different path casing, or symlink, and even when separate MCP - clients launched separate bridge server processes. Independent comparison runs still require - separate clean worktrees. The cross-process lock is an owner blob referenced by an atomic Git-ref +- 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. A stale idle lock is reclaimed only when its same-host owner is positively confirmed dead; malformed, foreign-host, starting, running, or uncertain records fail closed. A crashed bridge cannot reconstruct descendants that escaped into another POSIX session from the recorded worker PID alone, so inspect leftover processes and clear those hidden refs manually. -- Worktrees of the same repository serialize independently (each has its own lock), but Git refs - are shared by the whole repository. When another delegation was active in the same repository - during a run - detected from live leases plus the run-history record each completed delegation - leaves under a hidden `.history` ref - the result sets `repositoryConcurrency: true` and the - commits section is labelled as attributed rather than exact, because ref movements may include - the other worker's commits. Perfect attribution across shared refs would require serializing - the entire repository, which would break parallel independent-worktree runs. +- 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 worktree and index unchanged, but it requires writable Git object/ref metadata: each acquisition writes an owner blob and temporarily updates a hidden ref. Repository reference-transaction hooks can observe or reject those updates, and released owner blobs remain @@ -187,7 +182,7 @@ process-tree termination, escaped POSIX descendants and zombie-only Linux groups identity checks before signaling, unusual Git pathnames (including a trailing-space worktree root), JSON-RPC id typing, 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, repository-concurrency disclosure between linked worktrees, +new branches, non-commit refs, repository-wide serialization between linked worktrees, capture truncation, and Codex prompt delimiters on Windows and POSIX. ## License diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs index a2734f9..9d399de 100644 --- a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs @@ -36,14 +36,92 @@ function runUtility(command, args, timeoutMs = 5_000) { }); } +async function windowsProcessStartIdentity(pid, run = runUtility) { + const script = "$p=Get-CimInstance Win32_Process -Filter 'ProcessId = " + String(pid) + + "'; if ($null -ne $p) { $p.CreationDate.ToUniversalTime().Ticks.ToString() }"; + const result = await run("powershell.exe", [ + "-NoProfile", "-NonInteractive", "-Command", script, + ]); + if (result.exitCode !== 0) { + throw new Error("cannot inspect Windows process identity: " + + (result.stderr.trim() || result.error?.message || "unknown error")); + } + return result.stdout.trim() || null; +} + +export function trackedWindowsProcessTreePids(rootPid, treeState, processes) { + treeState.knownStarts ??= new Map(); + const byPid = new Map(processes.map((item) => [item.pid, item])); + const matchesIdentity = (item) => { + if (!item.startIdentity) { + throw new Error("cannot verify Windows process creation identity for PID " + String(item.pid)); + } + const expected = treeState.knownStarts.get(item.pid); + return !expected || expected === item.startIdentity; + }; + for (const pid of [...treeState.knownPids]) { + const item = byPid.get(pid); + const expected = treeState.knownStarts.get(pid); + if (item && expected && item.startIdentity && expected !== item.startIdentity) { + treeState.knownPids.delete(pid); + } + } + const descendants = new Set(); + const parents = new Set(); + const root = byPid.get(rootPid); + if (root && matchesIdentity(root)) { + const expectedRoot = treeState.knownStarts.get(rootPid); + if (expectedRoot || treeState.windowsSnapshotInitialized !== true) { + descendants.add(rootPid); + parents.add(rootPid); + treeState.knownStarts.set(rootPid, root.startIdentity); + } + } + // During the first relevant snapshot the root may have just exited while + // Win32_Process still records its children with the original parent PID. + if (treeState.windowsSnapshotInitialized !== true) parents.add(rootPid); + for (const pid of treeState.knownPids) { + const item = byPid.get(pid); + if (pid === rootPid && treeState.windowsSnapshotInitialized === true && + !treeState.knownStarts.has(pid)) continue; + if (!item || !matchesIdentity(item)) continue; + descendants.add(pid); + parents.add(pid); + } + let changed = true; + while (changed) { + changed = false; + for (const item of processes) { + if (!parents.has(item.parentPid) || descendants.has(item.pid)) continue; + if (!matchesIdentity(item)) continue; + descendants.add(item.pid); + parents.add(item.pid); + treeState.knownStarts.set(item.pid, item.startIdentity); + changed = true; + } + } + treeState.windowsSnapshotInitialized = 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 } = {}, ) { + const seeds = [...new Set([rootPid, ...treeState.knownPids])] + .filter((pid) => Number.isInteger(pid) && pid > 0) + .join(","); const script = [ "$ErrorActionPreference='Stop'", - "$items=@(Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,@{n='CreationTicks';e={$_.CreationDate.ToUniversalTime().Ticks}})", + `$seed=@(${seeds})`, + "$queue=New-Object 'System.Collections.Generic.Queue[uint32]'", + "$seed | ForEach-Object { $queue.Enqueue([uint32]$_) }", + "$expanded=@{}", + "$itemSeen=@{}", + "$items=@()", + "while($queue.Count -gt 0){$parent=$queue.Dequeue();if($expanded.ContainsKey($parent)){continue};$expanded[$parent]=$true;$filter=\"ProcessId = $parent OR ParentProcessId = $parent\";foreach($p in @(Get-CimInstance Win32_Process -Filter $filter)){if(-not $itemSeen.ContainsKey($p.ProcessId)){$itemSeen[$p.ProcessId]=$true;$identity=$(if($null -eq $p.CreationDate){''}else{$p.CreationDate.ToUniversalTime().Ticks.ToString()});$items += [pscustomobject]@{ProcessId=[uint32]$p.ProcessId;ParentProcessId=[uint32]$p.ParentProcessId;CreationTicks=$identity}};if(-not $expanded.ContainsKey($p.ProcessId)){$queue.Enqueue([uint32]$p.ProcessId)}}}", "$items | ConvertTo-Json -Compress", ].join("; "); const result = await run("powershell.exe", [ @@ -60,45 +138,46 @@ export async function windowsProcessTreePids( parentPid: Number(item.ParentProcessId), startIdentity: String(item.CreationTicks ?? ""), })); - treeState.knownStarts ??= new Map(); - const liveIdentities = new Map(processes.map((item) => [item.pid, item.startIdentity])); - // A known PID whose creation time changed is an unrelated process that reused - // the ID; it must leave the tracked tree before anything is signaled. - for (const pid of [...treeState.knownPids]) { - const expected = treeState.knownStarts.get(pid); - const observed = liveIdentities.get(pid); - if (expected && observed && expected !== observed) treeState.knownPids.delete(pid); - } - const livePids = new Set(processes.map((item) => item.pid)); - const descendants = new Set([...treeState.knownPids].filter((pid) => livePids.has(pid))); - if (livePids.has(rootPid)) { - const rootExpected = treeState.knownStarts.get(rootPid); - const rootObserved = liveIdentities.get(rootPid); - // Record the root identity on first observation; afterwards a mismatch - // means the backend PID already exited and was reused. - if (!rootExpected || !rootObserved || rootExpected === rootObserved) { - descendants.add(rootPid); - if (rootObserved) treeState.knownStarts.set(rootPid, rootObserved); - } - } - const parents = new Set([rootPid, ...treeState.knownPids]); - let changed = true; - while (changed) { - changed = false; - for (const item of processes) { - if (parents.has(item.parentPid) && !descendants.has(item.pid)) { - descendants.add(item.pid); - parents.add(item.pid); - changed = true; - } - } + return trackedWindowsProcessTreePids(rootPid, treeState, processes); +} + +export async function initializeProcessTree(child, treeState) { + if (!Number.isInteger(child.pid)) return; + if (process.platform === "win32") { + // taskkill /T starts from the live ChildProcess root. Defer CIM until + // close/termination so short-lived workers do not launch an expensive WMI + // query solely to prove that an already-closed root is gone. + treeState.knownStarts ??= new Map(); + return; } - for (const pid of descendants) { - const identity = liveIdentities.get(pid); - if (identity && !treeState.knownStarts.has(pid)) treeState.knownStarts.set(pid, identity); + await refreshProcessTree(child, treeState); +} + +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; +} + +async function readLinuxStat(pid, procRoot, fsOps) { + try { + return parseLinuxStat(pid, await fsOps.readFile(`${procRoot}/${pid}/stat`, "utf8")); + } catch (error) { + if (error.code === "ENOENT") return undefined; + throw error; } - for (const pid of descendants) treeState.knownPids.add(pid); - return [...descendants]; } async function linuxProcessSnapshot(procRoot = "/proc", fsOps = { readdir, readFile }) { @@ -111,28 +190,14 @@ async function linuxProcessSnapshot(procRoot = "/proc", fsOps = { readdir, readF const processes = []; for (const entry of entries) { if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue; - let statLine; try { - statLine = await fsOps.readFile(`${procRoot}/${entry.name}/stat`, "utf8"); + const item = await readLinuxStat(Number(entry.name), procRoot, fsOps); + if (item === undefined) continue; + if (item === null) return null; + processes.push(item); } catch (error) { - if (error.code === "ENOENT") continue; return null; } - // 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: Number(entry.name), - state: fields[0], - parentPid: Number(fields[1]), - processGroupId: Number(fields[2]), - startIdentity: fields[19] ?? "", // field 22: start time since boot - }; - if (!item.state || !Number.isInteger(item.pid) || - !Number.isInteger(item.parentPid) || !Number.isInteger(item.processGroupId)) return null; - processes.push(item); } processes.incomplete = false; return processes; @@ -156,6 +221,61 @@ export async function linuxProcessGroupHasLiveMembers( return members.some((item) => isLiveState(item.state)); } +// 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) { + treeState.knownStarts ??= new Map(); + const queue = [...new Set([rootPid, ...treeState.knownPids])]; + const queued = new Set(queue); + const processes = []; + for (let index = 0; index < queue.length; index += 1) { + const pid = queue[index]; + let item; + try { + item = await readLinuxStat(pid, procRoot, fsOps); + } catch { + return null; + } + if (item === undefined) continue; + if (item === null) return null; + const expected = treeState.knownStarts.get(pid); + if (expected && item.startIdentity && expected !== item.startIdentity) continue; + treeState.knownPids.add(pid); + if (item.startIdentity) treeState.knownStarts.set(pid, item.startIdentity); + processes.push(item); + + let taskEntries; + try { + taskEntries = await fsOps.readdir(`${procRoot}/${pid}/task`, { withFileTypes: true }); + } catch (error) { + if (error.code === "ENOENT") continue; + return null; + } + 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 (error.code === "ENOENT") 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 || queued.has(childPid)) continue; + queued.add(childPid); + queue.push(childPid); + } + } + } + processes.incomplete = false; + return processes; +} + async function posixProcessSnapshot({ platform = process.platform, procRoot = "/proc", @@ -190,7 +310,16 @@ export async function refreshProcessTree(child, treeState, options = {}) { await windowsProcessTreePids(child.pid, treeState, options); return null; } - const processes = await (options.posixProcessSnapshot ?? posixProcessSnapshot)(options); + const processes = options.posixProcessSnapshot + ? await options.posixProcessSnapshot(options) + : platform === "linux" + ? await linuxTrackedProcessSnapshot( + child.pid, + treeState, + options.procRoot ?? "/proc", + options.fsOps ?? { readdir, readFile }, + ) + : await posixProcessSnapshot(options); if (processes === null) return null; treeState.knownStarts ??= new Map(); const byPid = new Map(processes.map((item) => [item.pid, item])); @@ -234,6 +363,7 @@ export async function isProcessTreeAlive(child, treeState, { } = {}) { if (!Number.isInteger(child.pid)) return false; if (platform === "win32") { + await treeState.initialRefresh; return (await windowsProcessTreePids(child.pid, treeState)).length > 0; } const processes = await refreshProcessTree(child, treeState, { platform, procRoot, fsOps }); @@ -277,15 +407,17 @@ export async function signalProcessTree(child, signal, treeState, { } = {}) { if (!Number.isInteger(child.pid)) return; if (platform === "win32") { - // Windows has no portable SIGTERM equivalent for arbitrary console CLIs; - // /T /F is required to terminate the complete tree deterministically. - await run("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"]); - // If the root exited first, taskkill cannot traverse it. Win32_Process - // normally retains the old parent PID, while known PIDs survive re-parenting. - // windowsProcessTreePids drops any known PID whose creation identity changed, - // so reused PIDs are never signaled. + // The ChildProcess handle identifies the current root, so terminate its tree + // immediately. Retained PIDs are then checked by creation identity. + if (child.exitCode === null && child.signalCode === null) { + await run("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"]); + } + await treeState.initialRefresh; const remaining = await windowsProcessTreePids(child.pid, treeState, { runUtility: run }); for (const pid of remaining.reverse()) { + const expected = treeState.knownStarts.get(pid); + const current = await windowsProcessStartIdentity(pid, run); + if (!current || !expected || current !== expected) continue; await run("taskkill.exe", ["/PID", String(pid), "/T", "/F"]); } return; diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index ac941c2..1ff7aac 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -13,7 +13,7 @@ import os from "node:os"; import { fileURLToPath } from "node:url"; import path from "node:path"; -import { isProcessTreeAlive, refreshProcessTree, signalProcessTree, waitForChildExit, waitForProcessTreeExit } from "./process-tree.mjs"; +import { initializeProcessTree, isProcessTreeAlive, refreshProcessTree, signalProcessTree, waitForChildExit, waitForProcessTreeExit } from "./process-tree.mjs"; import { acquireGitWorkspaceLock, WORKSPACE_LOCK_REF_PREFIX, @@ -265,14 +265,15 @@ async function runCommand(command, args, options = {}) { knownPids: new Set(Number.isInteger(child.pid) ? [child.pid] : []), knownStarts: new Map(), }; - let treeRefreshActive = false; + let treeRefreshPromise = null; let treeRefreshTimer = null; - const refreshTree = async () => { - if (!manageProcessTree || treeRefreshActive) return; - treeRefreshActive = true; - try { await refreshProcessTree(child, treeState); } - catch (error) { terminationError ||= "process-tree inspection failed: " + error.message; } - finally { treeRefreshActive = false; } + const refreshTree = () => { + if (!manageProcessTree) return Promise.resolve(); + if (treeRefreshPromise) return treeRefreshPromise; + treeRefreshPromise = refreshProcessTree(child, treeState) + .catch((error) => { terminationError ||= "process-tree inspection failed: " + error.message; }) + .finally(() => { treeRefreshPromise = null; }); + return treeRefreshPromise; }; const timeoutMs = options.timeoutMs ?? 30_000; const killGraceMs = options.killGraceMs ?? KILL_GRACE_MS; @@ -334,11 +335,14 @@ async function runCommand(command, args, options = {}) { // Capture the root's start identity immediately so termination can later // detect a reused PID. Windows performs this one-shot inspection only; // POSIX keeps polling to track descendants that escape the process group. - // The 250 ms interval trades discovery latency against scanning every - // process under /proc (or forking `ps`) for the whole delegation. - void refreshTree(); + // Linux follows only tracked /proc task children, so a short interval + // catches session escapes without scanning the host process table. + treeState.initialRefresh = initializeProcessTree(child, treeState).catch((error) => { + terminationError ||= "process-tree initialization failed: " + error.message; + }); if (process.platform !== "win32") { - treeRefreshTimer = setInterval(() => { void refreshTree(); }, 250); + const refreshIntervalMs = process.platform === "linux" ? 25 : 250; + treeRefreshTimer = setInterval(() => { void refreshTree(); }, refreshIntervalMs); treeRefreshTimer.unref?.(); } } @@ -465,9 +469,24 @@ async function gitWorktreeRoot(workspacePath, options = {}) { } } -function workspaceLockKey(worktreeRoot) { - const normalized = path.normalize(worktreeRoot); - return "git-worktree:" + (process.platform === "win32" ? normalized.toLowerCase() : normalized); +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); + if (failure || !result.stdout.trim()) { + throw new Error("cannot identify Git common directory: " + (failure || "empty output")); + } + try { + return await realpath(path.resolve(workspacePath, result.stdout.trim())); + } catch (error) { + throw new Error("cannot canonicalize Git common directory: " + error.message); + } +} + +function repositoryLockKey(gitCommonDir) { + const normalized = path.normalize(gitCommonDir); + return "git-common-dir:" + (process.platform === "win32" ? normalized.toLowerCase() : normalized); } function snapshotFailure(label, result) { @@ -1126,6 +1145,7 @@ async function delegateTask(rawArgs, cancel) { const deadline = Date.now() + timeoutMs; let workspacePath = ""; let worktreeRoot = ""; + let gitCommonDir = ""; try { if (cancel?.cancelled) { return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); @@ -1134,6 +1154,7 @@ async function delegateTask(rawArgs, cancel) { 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 }); } catch (error) { if (error instanceof OperationCancelledError) { return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); @@ -1149,7 +1170,7 @@ async function delegateTask(rawArgs, cancel) { } throw error; } - const lockKey = workspaceLockKey(worktreeRoot); + const lockKey = repositoryLockKey(gitCommonDir); const existingQuarantine = await readWorkspaceQuarantine(lockKey); if (quarantinedWorkspaces.has(lockKey) || existingQuarantine) { return quarantinedDelegation({ backend, workspacePath, worktreeRoot, spec }, existingQuarantine); @@ -1583,6 +1604,7 @@ async function handleMessage(message) { const finishRequest = trackActiveRequest(message.id, cancel); let workspacePath = ""; let worktreeRoot = ""; + let gitCommonDir = ""; try { workspacePath = await validateWorkspace(args.workspacePath); if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath }); @@ -1590,6 +1612,7 @@ async function handleMessage(message) { await requireGitRepo(workspacePath, { cancel }); if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath }); worktreeRoot = await gitWorktreeRoot(workspacePath, { cancel }); + gitCommonDir = await gitCommonDirectory(workspacePath, { cancel }); } catch (error) { if (error instanceof OperationCancelledError) { return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); @@ -1597,7 +1620,7 @@ async function handleMessage(message) { throw error; } if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); - const lockKey = workspaceLockKey(worktreeRoot); + const lockKey = repositoryLockKey(gitCommonDir); const existingQuarantine = await readWorkspaceQuarantine(lockKey); if (quarantinedWorkspaces.has(lockKey) || existingQuarantine) { return quarantinedWorkspaceStatus( 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 index 94cd08f..253d85a 100644 --- a/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -12,8 +12,8 @@ inside the target git repository, and their results come back as a git diff for - 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 workspaces can run in parallel across different CLIs; - same-workspace delegations queue behind each other. +- 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 @@ -21,15 +21,14 @@ inside the target git repository, and their results come back as a git diff for 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 to the same workspace are serialized across bridge server processes, so parallel - runs from separate MCP clients still queue instead of interleaving edits. + 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. - When repositoryConcurrency is true, another delegation was active in the same repository - during the run, so the commits block lists attributed commits that may overlap the other - worker - do not present them as this worker's exclusive output. + 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 @@ -51,8 +50,9 @@ inside the target git repository, and their results come back as a git diff for 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 independent or comparison workers in separate clean Git worktrees at the same starting - commit. A second run in one checkout inherits the first run's edits and is not independent. +- 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. - 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 diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs index b2a747d..a014198 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +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"; @@ -13,12 +13,47 @@ import { windowsProcessTreePids, } from "../process-tree.mjs"; -async function writeProcStat(root, pid, { state, group, command = "worker" }) { +async function writeProcStat(root, pid, { + state, group, parent = 1, startIdentity = pid, command = "worker", +}) { const directory = path.join(root, String(pid)); - await mkdir(directory); - await writeFile(path.join(directory, "stat"), `${pid} (${command}) ${state} 1 ${group} ${group} 0 0 0 0\n`); + 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"); } +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, + }); + assert.deepEqual(new Set(snapshot.map((item) => item.pid)), new Set([601, 602])); + assert.equal(treeState.knownStarts.get(602), "11"); +}); + 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 })); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index 972171d..4251026 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -27,9 +27,14 @@ function currentUserLockRoot() { return path.join(os.tmpdir(), "minimax-cli-agent-bridge-locks-" + scope); } -function workspaceStatePaths(canonicalRoot) { - const normalized = path.normalize(canonicalRoot); - const key = "git-worktree:" + (process.platform === "win32" ? normalized.toLowerCase() : normalized); +async function canonicalGitCommonDirectory(workspace) { + const { stdout } = await execFileAsync("git", ["rev-parse", "--git-common-dir"], { cwd: workspace }); + return await realpath(path.resolve(workspace, stdout.trim())); +} + +function repositoryStatePaths(canonicalGitCommonDir) { + const normalized = path.normalize(canonicalGitCommonDir); + const key = "git-common-dir:" + (process.platform === "win32" ? normalized.toLowerCase() : normalized); const digest = createHash("sha256").update(key).digest("hex"); const root = currentUserLockRoot(); return { @@ -295,9 +300,9 @@ test("a cross-process lock waiter obeys the delegation deadline", async (context test("losing a Git-ref lease never strands the local FIFO gate", async (context) => { const { tempRoot, workspace, client } = await makeHarness(context); - const canonicalRoot = await realpath(workspace); + const canonicalRoot = await canonicalGitCommonDirectory(workspace); const normalized = path.normalize(canonicalRoot); - const key = "git-worktree:" + (process.platform === "win32" ? normalized.toLowerCase() : normalized); + const key = "git-common-dir:" + (process.platform === "win32" ? normalized.toLowerCase() : normalized); const ref = workspaceLockRef(key); const eventFile = path.join(tempRoot, "lost-lock-events.jsonl"); const first = client.request("tools/call", taskArguments(workspace, { @@ -362,11 +367,11 @@ test("unconfirmed termination after lease loss quarantines delegation and status let quarantinePath = null; try { await client.initialize(); - const canonicalRoot = await realpath(workspace); - ({ quarantinePath } = workspaceStatePaths(canonicalRoot)); + const canonicalRoot = await canonicalGitCommonDirectory(workspace); + ({ quarantinePath } = repositoryStatePaths(canonicalRoot)); context.after(() => rm(quarantinePath, { force: true })); const normalized = path.normalize(canonicalRoot); - const key = "git-worktree:" + normalized.toLowerCase(); + const key = "git-common-dir:" + normalized.toLowerCase(); ref = workspaceLockRef(key); const delegated = client.request("tools/call", taskArguments(workspace, { name: "unconfirmed-tree", eventFile, delayMs: 60_000, @@ -436,7 +441,9 @@ test("a quarantine marker blocks delegations in every server process", async (co const secondClient = new McpClient(configPath); try { await secondClient.initialize(); - const { root, quarantinePath } = workspaceStatePaths(await realpath(workspace)); + const { root, quarantinePath } = repositoryStatePaths( + await canonicalGitCommonDirectory(workspace), + ); await mkdir(root, { recursive: true }); context.after(() => rm(quarantinePath, { force: true })); await writeFile(quarantinePath, JSON.stringify({ terminationError: "fixture" })); @@ -792,33 +799,39 @@ test("a worker ref pointing at a non-commit object is reported without failing t assert.match(out.commits.log, /non-commit object/u); }); -test("parallel worktree delegations disclose repository concurrency", async (context) => { - const harness = await makeHarness(context); - const { tempRoot, workspace, configPath } = harness; - // A second independent worktree of the same repository: per-worktree locks - // are distinct while repository refs are shared. - const secondWorktree = path.join(tempRoot, "second-worktree"); - await execFileAsync("git", ["worktree", "add", secondWorktree], { cwd: workspace }); - - const eventFile = path.join(tempRoot, "parallel-events.log"); - const slowSpec = { name: "slow", delayMs: 3_000, eventFile }; - const fastClient = new McpClient(configPath); - await fastClient.initialize(); - context.after(async () => { await fastClient.close(); }); - - const slowPromise = harness.client.request("tools/call", taskArguments(workspace, slowSpec)); - await waitFor(async () => (await events(eventFile)).some((item) => item.event === "start")); - const overlapping = await fastClient.request("tools/call", taskArguments(secondWorktree, { - name: "fast-in-worktree", delayMs: 0, - })); - const overlappingOut = overlapping.result.structuredContent; - assert.equal(overlappingOut.ok, true, JSON.stringify(overlappingOut.error)); - assert.equal(overlappingOut.repositoryConcurrency, true, - "an overlapping delegation in a linked worktree must disclose concurrency"); - const slowOut = (await slowPromise).result.structuredContent; - assert.equal(slowOut.ok, true, JSON.stringify(slowOut.error)); - assert.equal(slowOut.repositoryConcurrency, true, - "the slow delegation observed the overlapping worker in its after snapshot"); +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(); + } }); From 47b4eca5aaabd87e8f710acbb9349a35fe7ed45f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 17:38:33 +0800 Subject: [PATCH 19/40] fix(cli-agent-bridge): close current review gaps --- .../Hylouis233/cli-agent-bridge/server.mjs | 22 +++++++----- .../cli-agent-bridge/tests/fake-backend.mjs | 15 ++++++++ .../cli-agent-bridge/tests/server.test.mjs | 35 +++++++++++++++++++ .../tests/workspace-lock.test.mjs | 24 +++++++++++++ .../cli-agent-bridge/workspace-lock.mjs | 6 +++- 5 files changed, 92 insertions(+), 10 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 1ff7aac..5d5813b 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -539,7 +539,7 @@ const CONCURRENT_LEASE_STALE_MS = 30_000; async function gitSnapshot(worktreeRoot, options = {}) { const ownLockRef = typeof options.ownLockRef === "string" ? options.ownLockRef : null; const jobs = [ - ["git status --short", "status", ["status", "--short"]], + ["git status --short", "status", ["status", "--short", "--untracked-files=all"]], ["git diff --stat", "diffStat", ["diff", "--stat"]], ["git diff --name-only -z", "diffNames", ["diff", "--name-only", "-z"]], ["git diff --cached --stat", "cachedDiffStat", ["diff", "--cached", "--stat"]], @@ -766,8 +766,11 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { // created from another divergent branch would otherwise attribute every // pre-existing difference between those branches to the worker. let base; - if (beforeOid) { - base = beforeOid; + const previousTarget = beforeOid + ? await peelCommitish(worktreeRoot, beforeOid, cache, options) + : null; + if (previousTarget) { + base = previousTarget; } else if (baselineCommits.length > 0) { const mergeBase = await runGitCommand( ["merge-base", target, ...baselineCommits.slice(0, 256)], @@ -1336,12 +1339,10 @@ async function delegateTask(rawArgs, cancel) { if (!result.treeTerminated) { quarantinedWorkspaces.add(lockKey); workspaceLease.retain(); - // Move the retained lease into the recoverable "quarantined" state. A - // running-state lease can never be reclaimed, which would leave the - // workspace locked forever even after the operator removes the marker. - try { - await workspaceLease.markWorkerQuarantined(); - } catch { /* lease lost or interrupted: the marker file below still gates recovery */ } + // Persist the operator-visible marker before making the retained lease + // recoverable. If persistence fails, the lease remains in the + // unreclaimable running state instead of treating a never-created marker + // as one that an operator deliberately removed. quarantinePath = await markWorkspaceQuarantined(lockKey, { backend, workspacePath, @@ -1349,6 +1350,9 @@ async function delegateTask(rawArgs, cancel) { lockRef: workspaceLease.ref, terminationError: result.terminationError, }); + try { + await workspaceLease.markWorkerQuarantined(); + } catch { /* running state remains fail-closed; the marker still explains manual recovery */ } // The shared marker is now authoritative and removable by an operator; // retain the local fallback only when writing that marker failed. quarantinedWorkspaces.delete(lockKey); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs index ecadef0..b83275a 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs @@ -59,6 +59,21 @@ if (spec.branchRoundTrip) { ).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.mode === "descendant") { event("descendant-start"); await delay(spec.delayMs ?? 1_000); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index 4251026..2be26e5 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -190,6 +190,20 @@ test("Codex templates delimit option-looking task text", async () => { assert.match(source, /resumeArgs: \["exec", "resume", "", "--", ""\]/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("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"); @@ -799,6 +813,27 @@ test("a worker ref pointing at a non-commit object is reported without failing t assert.match(out.commits.log, /non-commit object/u); }); +test("a ref moved from a blob to a new commit uses a commit-safe diff base", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + 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 response = await client.request("tools/call", taskArguments(workspace, { + name: "blob-to-commit", moveBlobRefToCommit: true, refName, + writeFile: "ref-commit.txt", commitMessage: "commit behind moved ref", + })); + assert.equal(response.result.error, undefined, "post-run attribution must not become a JSON-RPC error"); + const out = response.result.structuredContent; + assert.equal(out.ok, true, JSON.stringify(out.error)); + assert.match(out.commits.log, /commit behind moved ref/u); + assert.match(out.commits.diffStat, /ref-commit\.txt/u); + assert.doesNotMatch(out.commits.diffStat, new RegExp(blobOid.trim(), "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"); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs index 7da9ceb..a63f323 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs @@ -307,6 +307,7 @@ test("quarantined leases are reclaimable after the operator clears the marker", hostIdentity: localHostIdentity(), ownerPid: process.pid, workerState: "quarantined", + quarantineMarkerPersisted: true, workerPid: 4242, acquiredAt: now, heartbeatAt: now, @@ -326,6 +327,28 @@ test("quarantined leases are reclaimable after the operator clears the marker", 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", + operatorCleared: () => true, + }); + assert.deepEqual(result, { acquired: false, reason: "held" }, + "an absent marker is not operator clearance unless persistence was recorded"); +}); + test("a quarantined lease left by a crashed owner is reclaimable after the stale window", async (context) => { const repo = await makeRepo(context); const key = "git-worktree:" + repo; @@ -336,6 +359,7 @@ test("a quarantined lease left by a crashed owner is reclaimable after the stale hostIdentity: localHostIdentity(), ownerPid: 12345, workerState: "quarantined", + quarantineMarkerPersisted: true, workerPid: 5353, acquiredAt: now - 120_000, heartbeatAt: now - 120_000, diff --git a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs index c7806ef..53e01db 100644 --- a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs @@ -237,6 +237,10 @@ async function canReclaim(owner, { // marker is deliberately removed (or, failing that, the owner died and the // heartbeat went stale after a crash). 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 (operatorCleared) { try { if (await operatorCleared()) return true; @@ -345,7 +349,7 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { await queueUpdate({ workerState: "idle", workerPid: null }, interrupt); }, async markWorkerQuarantined() { - await queueUpdate({ workerState: "quarantined" }); + await queueUpdate({ workerState: "quarantined", quarantineMarkerPersisted: true }); }, retain() { retained = true; From 36988ef1c40a543a579135e9d97f06acc7c80d50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 18:37:30 +0800 Subject: [PATCH 20/40] fix(cli-agent-bridge): address delayed review findings --- plugins/Hylouis233/cli-agent-bridge/README.md | 18 ++- .../cli-agent-bridge/process-tree.mjs | 72 +++++++++--- .../Hylouis233/cli-agent-bridge/server.mjs | 106 +++++++++++++++--- .../cli-agent-bridge/tests/fake-backend.mjs | 23 ++++ .../tests/process-tree.test.mjs | 42 +++++++ .../cli-agent-bridge/tests/server.test.mjs | 83 ++++++++++++++ .../tests/workspace-lock.test.mjs | 78 +++++++++++++ .../cli-agent-bridge/workspace-lock.mjs | 44 ++++++-- 8 files changed, 423 insertions(+), 43 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 44b832c..466d600 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -119,7 +119,10 @@ you already obtained a valid ID from that backend outside this Plugin. 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. A stale idle lock is reclaimed only when its same-host owner is positively - confirmed dead; malformed, foreign-host, starting, running, or uncertain records fail closed. + 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 cannot reconstruct descendants that escaped into another POSIX session from the recorded worker PID alone, so inspect leftover processes and clear those hidden refs manually. - Linked worktrees share refs and therefore intentionally share one repository lock. The @@ -136,7 +139,10 @@ you already obtained a valid ID from that backend outside this Plugin. session/process group so cancellation still terminates them; tracked PIDs are matched against their recorded start identity (process start time on POSIX, creation time on Windows) so a reused PID is never signaled, and a POSIX process group is only signaled while its original - leader identity still matches. If termination cannot be confirmed, the bridge writes a shared + leader identity still matches. On Linux, descendants also inherit a per-run environment marker; + if the parent exits before ancestry polling, the close path performs one marker scan to recover + reparented children without continuously scanning all of `/proc`. If termination cannot be + confirmed, the bridge writes a shared quarantine marker, moves its lease into the recoverable `quarantined` state, and every bridge process refuses further delegation until an operator checks for leftovers and deliberately removes the reported quarantinePath - removing that marker also authorizes the next delegation @@ -156,7 +162,10 @@ you already obtained a valid ID from that backend outside this Plugin. 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. Any + 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; + remote-tracking updates are treated as externally sourced fetch history and excluded from worker + attribution, including when a local worker commit builds on the fetched tip. 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, @@ -182,7 +191,8 @@ process-tree termination, escaped POSIX descendants and zombie-only Linux groups identity checks before signaling, unusual Git pathnames (including a trailing-space worktree root), JSON-RPC id typing, 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, repository-wide serialization between linked worktrees, +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 diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs index 9d399de..4165fb8 100644 --- a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs @@ -221,6 +221,32 @@ export async function linuxProcessGroupHasLiveMembers( return members.some((item) => isLiveState(item.state)); } +async function linuxMarkedProcessPids(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 = []; + for (const entry of entries) { + if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue; + try { + 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)) matches.push(Number(entry.name)); + } 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 (!["ENOENT", "EACCES", "EPERM"].includes(error.code)) return null; + } + } + return matches; +} + // 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. @@ -237,7 +263,18 @@ async function linuxTrackedProcessSnapshot(rootPid, treeState, procRoot, fsOps) } catch { return null; } - if (item === undefined) continue; + if (item === undefined) { + if (pid === rootPid && treeState.runMarker) { + const markedPids = await linuxMarkedProcessPids(treeState.runMarker, procRoot, fsOps); + if (markedPids === null) return null; + for (const markedPid of markedPids) { + if (queued.has(markedPid)) continue; + queued.add(markedPid); + queue.push(markedPid); + } + } + continue; + } if (item === null) return null; const expected = treeState.knownStarts.get(pid); if (expected && item.startIdentity && expected !== item.startIdentity) continue; @@ -282,23 +319,32 @@ async function posixProcessSnapshot({ fsOps = { readdir, readFile }, } = {}) { if (platform === "linux") return await linuxProcessSnapshot(procRoot, fsOps); - const result = await runUtility("ps", ["-axo", "pid=,ppid=,pgid=,stat="]); + const result = await runUtility("ps", ["-axo", "pid=,ppid=,pgid=,stat=,lstart="]); if (result.exitCode !== 0) return null; - const processes = result.stdout.split(/\r?\n/u).flatMap((line) => { - const fields = line.trim().split(/\s+/u); - if (fields.length < 4) return []; - return [{ - pid: Number(fields[0]), - parentPid: Number(fields[1]), - processGroupId: Number(fields[2]), - state: fields[3][0] ?? "", - startIdentity: "", - }]; - }).filter((item) => Number.isInteger(item.pid)); + const processes = result.stdout.split(/\r?\n/u) + .map(parsePosixProcessLine) + .filter(Boolean); 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"; } diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 5d5813b..1c1b6c9 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -235,17 +235,23 @@ async function runCommand(command, args, options = {}) { return; } const manageProcessTree = options.manageProcessTree === true; + const linuxRunMarker = manageProcessTree && process.platform === "linux" + ? randomUUID() + : null; + const childEnvironment = linuxRunMarker + ? { ...process.env, CLI_AGENT_BRIDGE_RUN_ID: linuxRunMarker } + : process.env; const child = shellArgs ? spawn(shellArgs[0], shellArgs.slice(1), { cwd: options.cwd, - env: process.env, + env: childEnvironment, detached: manageProcessTree && process.platform !== "win32", windowsHide: true, stdio: [options.stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"], }) : spawn(command, argv, { cwd: options.cwd, - env: process.env, + env: childEnvironment, detached: manageProcessTree && process.platform !== "win32", windowsHide: true, stdio: [options.stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"], @@ -264,6 +270,7 @@ async function runCommand(command, args, options = {}) { const treeState = { knownPids: new Set(Number.isInteger(child.pid) ? [child.pid] : []), knownStarts: new Map(), + runMarker: linuxRunMarker, }; let treeRefreshPromise = null; let treeRefreshTimer = null; @@ -698,6 +705,13 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { ])) { await addBaseline(oid); } + // Commits introduced by fetch live under remote-tracking refs and were + // created outside this worker. Add their after-state tips to the exclusion + // baseline before attributing any local branch/tag that builds on them. + const externalRefChanges = refsChanged.filter((change) => + change.ref.startsWith("refs/remotes/"), + ); + for (const change of externalRefChanges) await addBaseline(change.after); // 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 @@ -716,12 +730,16 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { } addTarget("HEAD", before.head, after.head); for (const change of refsChanged) { + if (change.ref.startsWith("refs/remotes/")) continue; addTarget(change.ref, change.before, change.after); } - const logs = []; - const stats = []; - let newCommitCount = 0; + const movementLogs = externalRefChanges.map((change) => + change.ref + " moved to externally sourced history; excluded from worker-created commits", + ); + 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(", "); @@ -729,8 +747,8 @@ async function committedDelta(worktreeRoot, before, after, 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. - logs.push(label + " -> " + afterOid + " (non-commit object; no commit log)"); - stats.push(label + ": (non-commit ref target)"); + 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 @@ -738,8 +756,8 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { const exclusions = baselineCommits; const revList = await runGitCommand( exclusions.length > 0 - ? ["log", "--oneline", target, "--stdin"] - : ["log", "--oneline", target], + ? ["log", "--format=%H%x09%s", target, "--stdin"] + : ["log", "--format=%H%x09%s", target], { cwd: worktreeRoot, ...options, @@ -756,11 +774,21 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { ? "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"; - logs.push(label + ": " + note); - stats.push(label + ": (no new commits)"); + movementLogs.push(label + ": " + note); + statNotes.push(label + ": (no new commits)"); continue; } - newCommitCount += newCommits.split("\n").length; + 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) }); + } + } // For a ref that did not exist before, diff from its best common ancestor // with the pre-delegation state, not from the original HEAD: a new branch // created from another divergent branch would otherwise attribute every @@ -788,13 +816,34 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { }); const diffFailure = snapshotFailure("git diff --stat " + range, diff); if (diffFailure) throw new Error("committed delta unreliable: " + diffFailure); - logs.push(label + " [" + range + "]\n" + newCommits); - stats.push(label + " [" + range + "]\n" + (String(diff.stdout ?? "").trim() || "(empty)")); + 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, + newCommitCount: attributedCommits.size, log: logs.join("\n\n") || "(no ref or HEAD movements)", diffStat: stats.join("\n\n") || "(empty)", }; @@ -900,8 +949,8 @@ async function processStartIdentity(pid) { if (error.code === "ENOENT") return null; } } else if (process.platform === "win32") { - const script = "$p=Get-CimInstance Win32_Process -Filter 'ProcessId = " + String(pid) + - "'; if ($null -ne $p) { $p.CreationDate.ToUniversalTime().Ticks }"; + const script = "$p=Get-Process -Id " + String(pid) + + " -ErrorAction SilentlyContinue; if ($null -ne $p) { $p.StartTime.ToUniversalTime().Ticks }"; const result = await runCommand("powershell.exe", [ "-NoProfile", "-NonInteractive", "-Command", script, ], { timeoutMs: 5_000 }); @@ -927,6 +976,17 @@ async function cachedProcessStartIdentity(pid) { 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. @@ -976,7 +1036,15 @@ async function withWorkspaceLock(key, worktreeRoot, fn, { return typeof onUnavailable === "function" ? onUnavailable() : undefined; } try { - lease = await acquireGitWorkspaceLock({ cwd: worktreeRoot, key, cancel, deadline, operatorCleared }); + lease = await acquireGitWorkspaceLock({ + cwd: worktreeRoot, + key, + cancel, + deadline, + operatorCleared, + ownerIdentity: await serverProcessStartIdentity(), + processIdentityProbe: processStartIdentity, + }); } catch (error) { if (error instanceof WorkspaceLockCancelledError) { return typeof onCancelled === "function" ? onCancelled() : undefined; @@ -1414,6 +1482,8 @@ async function delegateTask(rawArgs, cancel) { error = "backend \"" + backend + "\" timed out after " + timeoutMs + " ms" + (result.killed ? " and was force-killed" : ""); } 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); } diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs index b83275a..fd2bc9e 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs @@ -37,6 +37,29 @@ if (spec.branchRoundTrip) { 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"]); + 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.newBranchFromExisting) { // Fork a new branch from a pre-existing divergent branch, commit, and return. diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs index a014198..c947980 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs @@ -7,12 +7,26 @@ import test from "node:test"; import { isProcessTreeAlive, linuxProcessGroupHasLiveMembers, + parsePosixProcessLine, 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", }) { @@ -30,6 +44,13 @@ async function writeTaskChildren(root, pid, children) { 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 })); @@ -54,6 +75,27 @@ test("Linux ancestry refresh follows task children without scanning all of procf assert.equal(treeState.knownStarts.get(602), "11"); }); +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"); +}); + 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 })); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index 2be26e5..5f0fed8 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -588,6 +588,28 @@ test("cancellation terminates a descendant that creates a new POSIX session", { 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("timeout terminates descendants before releasing the request", async (context) => { const { tempRoot, workspace, client } = await makeHarness(context); const eventFile = path.join(tempRoot, "events.jsonl"); @@ -887,6 +909,24 @@ test("a commit on the checked-out branch is reported exactly once", async (conte "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 }); @@ -909,6 +949,24 @@ test("a new branch forked from a divergent branch diffs only its own commits", a assert.match(out.commits.diffStat, /fork\.txt/u); }); +test("fetched remote history is excluded from 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, 1, out.commits.log); + assert.match(out.commits.log, /worker commit after fetch/u); + assert.doesNotMatch(out.commits.log, /fetched upstream commit/u); + assert.match(out.commits.log, /refs\/remotes\/origin\/main moved to externally sourced history/u); + assert.doesNotMatch(out.commits.diffStat, /upstream\.txt/u, + "external fetched content is part of the attribution baseline"); + assert.match(out.commits.diffStat, /worker-after-fetch\.txt/u); +}); + 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 }); }); @@ -939,6 +997,31 @@ test("list_backends can be cancelled while a version probe hangs", async (contex assert.ok(Array.isArray(response.result.structuredContent.backends)); }); +test("backend spawn failures report the launch error", { + 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, /failed to start.*ENOENT/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 () => { diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs index a63f323..88b83b3 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs @@ -104,6 +104,36 @@ test("a stale lock with a live owner is never stolen", async (context) => { 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; @@ -199,6 +229,31 @@ test("a failed release can be recovered by the next local holder in every comple } }); +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); + first.lease.allowLocalRecovery(); + await rm(blocker, { force: true }); + + const second = await tryAcquireGitWorkspaceLock({ cwd: linked, key, heartbeatMs: 60_000 }); + assert.equal(second.acquired, true, + "the repository-scoped abandoned OID is visible from every linked worktree"); + await second.lease.release(); +}); + test("post-CAS cancellation remains recoverable when its compensating delete fails", async (context) => { const repo = await makeRepo(context); const key = "git-worktree:" + repo; @@ -349,6 +404,29 @@ test("a quarantined lease without proof of a durable marker fails closed", async "an absent marker is not operator clearance unless persistence was recorded"); }); +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, + workerPid: 5353, + acquiredAt: now - 120_000, + heartbeatAt: now - 120_000, + }); + const result = await tryAcquireGitWorkspaceLock({ + cwd: repo, key, now, staleMs: 30_000, processProbe: () => "dead", + operatorCleared: () => true, + }); + assert.deepEqual(result, { acquired: false, reason: "held" }); +}); + test("a quarantined lease left by a crashed owner is reclaimable after the stale window", async (context) => { const repo = await makeRepo(context); const key = "git-worktree:" + repo; diff --git a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs index 53e01db..332fa88 100644 --- a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs @@ -16,13 +16,24 @@ const RELEASE_ATTEMPTS = 3; // A failed release may leave this process's exact owner blob installed. The // server registers that OID while its in-process FIFO gate is still held, so // only the next local holder may replace it after the failed request unwinds. +// The ref already includes the repository-scoped key; do not add a worktree +// cwd, because linked worktrees share this same lease. const locallyAbandonedRefs = new Map(); export class WorkspaceLockCancelledError extends Error {} export class WorkspaceLockDeadlineError extends Error {} export function localHostIdentity() { - return `${process.platform}:${os.hostname().toLowerCase()}`; + 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) { @@ -228,6 +239,7 @@ async function canReclaim(owner, { staleMs, hostIdentity, processProbe, + processIdentityProbe = null, operatorCleared = null, }) { if (!owner || owner.version !== 1 || owner.hostIdentity !== hostIdentity) return false; @@ -246,10 +258,11 @@ async function canReclaim(owner, { if (await operatorCleared()) return true; } catch { /* treat a failed check as not cleared */ } } - return now - owner.heartbeatAt >= staleMs && await processProbe(owner.ownerPid) === "dead"; + return now - owner.heartbeatAt >= staleMs && + await originalOwnerStatus(owner, processProbe, processIdentityProbe) === "dead"; } if (now - owner.heartbeatAt < staleMs) return false; - if (await processProbe(owner.ownerPid) !== "dead") 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 @@ -257,12 +270,25 @@ async function canReclaim(owner, { return false; } -function makeOwner({ hostIdentity, ownerPid, now }) { +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, @@ -271,7 +297,7 @@ function makeOwner({ hostIdentity, ownerPid, now }) { } function createLease({ cwd, ref, oid, owner, heartbeatMs }) { - const localRefKey = cwd + "\0" + ref; + const localRefKey = ref; const ownerToken = owner.token; let currentOid = oid; let currentOwner = owner; @@ -416,23 +442,25 @@ export async function tryAcquireGitWorkspaceLock({ heartbeatMs = DEFAULT_HEARTBEAT_MS, hostIdentity = localHostIdentity(), ownerPid = process.pid, + ownerIdentity = null, now = Date.now(), processProbe = probeProcess, + processIdentityProbe = null, operatorCleared = null, } = {}) { checkInterrupted(cancel, deadline); const ref = workspaceLockRef(key); - const localRefKey = cwd + "\0" + ref; + const localRefKey = ref; const current = await readCurrentOwner(cwd, ref, { cancel, deadline }); const abandonedOid = locallyAbandonedRefs.get(localRefKey); const locallyAbandoned = Boolean(current && abandonedOid === current.oid); if (!current || (abandonedOid && !locallyAbandoned)) locallyAbandonedRefs.delete(localRefKey); if (current && !locallyAbandoned && !await canReclaim(current.owner, { - now, staleMs, hostIdentity, processProbe, operatorCleared, + now, staleMs, hostIdentity, processProbe, processIdentityProbe, operatorCleared, })) { return { acquired: false, reason: "held" }; } - const owner = makeOwner({ hostIdentity, ownerPid, now }); + const owner = makeOwner({ hostIdentity, ownerPid, ownerIdentity, now }); const newOid = await writeOwnerBlob(cwd, owner, { cancel, deadline }); let acquired = false; if (!current) { From 183aa047d490fcdb9cc25c9d6a31f9a14ee3d49f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 19:23:10 +0800 Subject: [PATCH 21/40] fix(cli-agent-bridge): cover complete baselines and paths --- .../Hylouis233/cli-agent-bridge/server.mjs | 68 ++++++++++++++++--- .../cli-agent-bridge/tests/server.test.mjs | 59 +++++++++++++++- 2 files changed, 117 insertions(+), 10 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 1c1b6c9..d8e9722 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -481,11 +481,12 @@ async function gitCommonDirectory(workspacePath, options = {}) { cwd: workspacePath, ...options, }); const failure = snapshotFailure("git rev-parse --git-common-dir", result); - if (failure || !result.stdout.trim()) { + const output = result.stdout.replace(/\r?\n$/u, ""); + if (failure || !output) { throw new Error("cannot identify Git common directory: " + (failure || "empty output")); } try { - return await realpath(path.resolve(workspacePath, result.stdout.trim())); + return await realpath(path.resolve(workspacePath, output)); } catch (error) { throw new Error("cannot canonicalize Git common directory: " + error.message); } @@ -663,6 +664,61 @@ async function peelCommitish(worktreeRoot, oid, cache = new Map(), options = {}) return commit; } +async function closestExistingBase(worktreeRoot, target, baselineCommits, options = {}) { + async function distanceFromTarget(candidate) { + const distance = await runGitCommand(["rev-list", "--count", candidate + ".." + target], { + cwd: worktreeRoot, ...options, + }); + const failure = snapshotFailure("git rev-list --count " + candidate + ".." + target, distance); + const count = Number(distance.stdout.trim()); + if (failure || !Number.isInteger(count)) { + throw new Error("cannot select committed-delta baseline: " + (failure || "invalid distance")); + } + return count; + } + + let best = null; + let bestDistance = Number.POSITIVE_INFINITY; + // Check every pre-run tip in bounded, cancellable commands. This avoids a + // command-line-size limit and never silently drops late refs from attribution. + for (const candidate of baselineCommits) { + const ancestor = await runGitCommand(["merge-base", "--is-ancestor", candidate, target], { + cwd: worktreeRoot, ...options, + }); + if (ancestor.timedOut || ancestor.stdoutTruncated || ancestor.stderrTruncated || + ![0, 1].includes(ancestor.exitCode)) { + throw new Error("cannot select committed-delta baseline: git merge-base --is-ancestor failed"); + } + if (ancestor.exitCode !== 0) continue; + const distance = await distanceFromTarget(candidate); + if (distance < bestDistance) { + best = candidate; + bestDistance = distance; + } + } + if (best) return best; + + // Rewritten histories may have no pre-run tip that remains a direct ancestor. + // Evaluate each merge base independently and retain the closest one. + for (const candidate of baselineCommits) { + const mergeBase = await runGitCommand(["merge-base", target, candidate], { + cwd: worktreeRoot, ...options, + }); + if (mergeBase.exitCode === 1 && !mergeBase.timedOut) continue; + const failure = snapshotFailure("git merge-base " + target + " " + candidate, mergeBase); + const merged = mergeBase.stdout.trim(); + if (failure || !merged) { + throw new Error("cannot select committed-delta baseline: " + (failure || "empty merge base")); + } + const distance = await distanceFromTarget(merged); + if (distance < bestDistance) { + best = merged; + bestDistance = distance; + } + } + return best; +} + 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) => { @@ -800,13 +856,7 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { if (previousTarget) { base = previousTarget; } else if (baselineCommits.length > 0) { - const mergeBase = await runGitCommand( - ["merge-base", target, ...baselineCommits.slice(0, 256)], - { cwd: worktreeRoot, ...options }, - ); - base = mergeBase.exitCode === 0 && mergeBase.stdout.trim() - ? mergeBase.stdout.trim() - : await emptyTree(); + base = await closestExistingBase(worktreeRoot, target, baselineCommits, options) ?? await emptyTree(); } else { base = before.head || await emptyTree(); } diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index 5f0fed8..e68fce5 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -29,7 +29,7 @@ function currentUserLockRoot() { async function canonicalGitCommonDirectory(workspace) { const { stdout } = await execFileAsync("git", ["rev-parse", "--git-common-dir"], { cwd: workspace }); - return await realpath(path.resolve(workspace, stdout.trim())); + return await realpath(path.resolve(workspace, stdout.replace(/\r?\n$/u, ""))); } function repositoryStatePaths(canonicalGitCommonDir) { @@ -949,6 +949,41 @@ test("a new branch forked from a divergent branch diffs only its own commits", a 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("fetched remote history is excluded from worker-created commits", async (context) => { const { workspace, client } = await makeHarness(context); const response = await client.request("tools/call", taskArguments(workspace, { @@ -1059,3 +1094,25 @@ test("a worktree root ending in whitespace is canonicalized without trimming it" 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)); +}); From d91e9233ec5646739aa1bf83b3a356c1bf0e1f53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 19:48:27 +0800 Subject: [PATCH 22/40] fix(cli-agent-bridge): observe late children and fetched tags --- plugins/Hylouis233/cli-agent-bridge/README.md | 10 ++++--- .../cli-agent-bridge/process-tree.mjs | 28 +++++++++++++++---- .../Hylouis233/cli-agent-bridge/server.mjs | 25 ++++++++++++++--- .../cli-agent-bridge/tests/fake-backend.mjs | 6 +++- .../tests/process-tree.test.mjs | 28 +++++++++++++++++++ .../cli-agent-bridge/tests/server.test.mjs | 17 +++++++++++ 6 files changed, 100 insertions(+), 14 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 466d600..86943a6 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -140,8 +140,9 @@ you already obtained a valid ID from that backend outside this Plugin. their recorded start identity (process start time on POSIX, creation time on Windows) so a reused PID is never signaled, and a POSIX process group is only signaled while its original leader identity still matches. On Linux, descendants also inherit a per-run environment marker; - if the parent exits before ancestry polling, the close path performs one marker scan to recover - reparented children without continuously scanning all of `/proc`. If termination cannot be + if the parent exits before ancestry polling, the close path uses a bounded observation grace and + marker scans to recover children that become visible just after the leader exits, without + continuously scanning all of `/proc`. If termination cannot be confirmed, the bridge writes a shared quarantine marker, moves its lease into the recoverable `quarantined` state, and every bridge process refuses further delegation until an operator checks for leftovers and deliberately @@ -164,8 +165,9 @@ you already obtained a valid ID from that backend outside this Plugin. 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; - remote-tracking updates are treated as externally sourced fetch history and excluded from worker - attribution, including when a local worker commit builds on the fetched tip. Any + remote-tracking updates and fetched tag-only tips are treated as externally sourced history and + excluded from worker attribution, including when a local worker commit builds on the fetched + tip. 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, diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs index 4165fb8..019c490 100644 --- a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs @@ -2,6 +2,7 @@ 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; @@ -412,11 +413,11 @@ export async function isProcessTreeAlive(child, treeState, { await treeState.initialRefresh; return (await windowsProcessTreePids(child.pid, treeState)).length > 0; } - const processes = await refreshProcessTree(child, treeState, { platform, procRoot, fsOps }); - if (processes !== null) { + let processes = await refreshProcessTree(child, treeState, { platform, procRoot, fsOps }); + const snapshotHasTrackedLive = (snapshot) => { const knownStarts = treeState.knownStarts ?? new Map(); const leaderStart = knownStarts.get(child.pid); - const trackedLive = processes.some((item) => { + return snapshot.some((item) => { if (!isLiveState(item.state)) return false; if (item.processGroupId === child.pid) { // A reused PID leading an unrelated group must not count as our tree. @@ -426,8 +427,25 @@ export async function isProcessTreeAlive(child, treeState, { const expected = knownStarts.get(item.pid); return !expected || !item.startIdentity || expected === item.startIdentity; }); - if (trackedLive) return true; - if (ignoreZombieOnly && !processes.incomplete) return false; + }; + 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 observationDeadline = Date.now() + MARKER_OBSERVATION_GRACE_MS; + while (Date.now() < observationDeadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + processes = await refreshProcessTree(child, treeState, { platform, procRoot, fsOps }); + if (processes === null) break; + if (snapshotHasTrackedLive(processes)) return true; + } + treeState.markerObservationComplete = true; + } + if (processes !== null && ignoreZombieOnly && !processes.incomplete) return false; } try { probeProcessGroup(child.pid); diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index d8e9722..a2e0a87 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -764,10 +764,27 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { // Commits introduced by fetch live under remote-tracking refs and were // created outside this worker. Add their after-state tips to the exclusion // baseline before attributing any local branch/tag that builds on them. - const externalRefChanges = refsChanged.filter((change) => - change.ref.startsWith("refs/remotes/"), - ); + const movedLocalTargets = new Set([ + before.head === after.head ? "" : after.head, + ...refsChanged + .filter((change) => change.ref.startsWith("refs/heads/")) + .map((change) => change.after), + ].filter(Boolean)); + const externalRefChanges = []; + for (const change of refsChanged) { + if (change.ref.startsWith("refs/remotes/")) { + externalRefChanges.push(change); + continue; + } + if (!change.ref.startsWith("refs/tags/") || !change.after) continue; + const tagCommit = await peelCommitish(worktreeRoot, change.after, cache, options); + // A tag arriving without a moved local HEAD/branch at the same commit is + // conservatively treated as fetch-sourced. A tag attached to the worker's + // exact new local tip remains a contributing attribution label. + if (tagCommit && !movedLocalTargets.has(tagCommit)) externalRefChanges.push(change); + } for (const change of externalRefChanges) await addBaseline(change.after); + const externalRefNames = new Set(externalRefChanges.map((change) => change.ref)); // 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 @@ -786,7 +803,7 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { } addTarget("HEAD", before.head, after.head); for (const change of refsChanged) { - if (change.ref.startsWith("refs/remotes/")) continue; + if (externalRefNames.has(change.ref)) continue; addTarget(change.ref, change.before, change.after); } diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs index fd2bc9e..f5abd66 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs @@ -54,7 +54,11 @@ if (spec.branchRoundTrip) { const upstreamOid = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); execFileSync("git", ["checkout", original]); execFileSync("git", ["branch", "-D", "fixture-upstream"]); - execFileSync("git", ["update-ref", "refs/remotes/origin/main", upstreamOid]); + if (spec.fetchTagOnly) { + execFileSync("git", ["update-ref", "refs/tags/fetched-tag", 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"]); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs index c947980..29f2e4b 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs @@ -96,6 +96,34 @@ test("Linux refresh recovers a marked detached child after its parent exits", as "the inherited run marker preserves containment after orphan reparenting"); }); +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 })); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index e68fce5..bf2663b 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -1002,6 +1002,23 @@ test("fetched remote history is excluded from worker-created commits", async (co assert.match(out.commits.diffStat, /worker-after-fetch\.txt/u); }); +test("a fetched tag tip is an external baseline for later worker 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, 1, out.commits.log); + assert.match(out.commits.log, /worker commit after fetched tag/u); + assert.doesNotMatch(out.commits.log, /fetched upstream commit/u); + assert.match(out.commits.log, /refs\/tags\/fetched-tag moved to externally sourced history/u); + assert.doesNotMatch(out.commits.diffStat, /upstream\.txt/u); + assert.match(out.commits.diffStat, /worker-after-tag\.txt/u); +}); + 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 }); }); From 8e6c5aaff6a0d4a34bacf8930b424664a8127f82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 20:09:50 +0800 Subject: [PATCH 23/40] fix(cli-agent-bridge): preserve moved tag attribution --- plugins/Hylouis233/cli-agent-bridge/server.mjs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index a2e0a87..4d724ee 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -776,11 +776,12 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { externalRefChanges.push(change); continue; } - if (!change.ref.startsWith("refs/tags/") || !change.after) continue; + if (!change.ref.startsWith("refs/tags/") || change.before || !change.after) continue; const tagCommit = await peelCommitish(worktreeRoot, change.after, cache, options); - // A tag arriving without a moved local HEAD/branch at the same commit is - // conservatively treated as fetch-sourced. A tag attached to the worker's - // exact new local tip remains a contributing attribution label. + // A newly arriving tag without a moved local HEAD/branch at the same commit + // is conservatively treated as fetch-sourced. Existing tags may be moved by + // the worker (including from a non-commit object) and remain attribution + // labels because a before/after snapshot cannot prove such a move was fetch. if (tagCommit && !movedLocalTargets.has(tagCommit)) externalRefChanges.push(change); } for (const change of externalRefChanges) await addBaseline(change.after); From c6e6ec7043952b52820e706020c6e3be0266599f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 20:59:20 +0800 Subject: [PATCH 24/40] fix(cli-agent-bridge): close current review gaps --- .../cli-agent-bridge/process-tree.mjs | 14 +- .../Hylouis233/cli-agent-bridge/server.mjs | 107 +++++++++---- .../cli-agent-bridge/tests/fake-backend.mjs | 16 ++ .../tests/process-tree.test.mjs | 41 +++++ .../cli-agent-bridge/tests/server.test.mjs | 142 +++++++++++++++++- .../tests/workspace-lock.test.mjs | 53 ++++++- .../cli-agent-bridge/workspace-lock.mjs | 6 +- 7 files changed, 339 insertions(+), 40 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs index 019c490..8c68dbe 100644 --- a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs @@ -373,14 +373,19 @@ export async function refreshProcessTree(child, treeState, options = {}) { // 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); - if (leader?.startIdentity && !treeState.knownStarts.has(child.pid)) { + const expectedLeaderStart = treeState.knownStarts.get(child.pid); + const leaderIsOriginal = Boolean(leader) && + (!expectedLeaderStart || !leader.startIdentity || leader.startIdentity === expectedLeaderStart); + if (leaderIsOriginal && leader.startIdentity && !expectedLeaderStart) { treeState.knownStarts.set(child.pid, leader.startIdentity); } const matchesKnownIdentity = (item) => { const expected = treeState.knownStarts.get(item.pid); return !expected || !item.startIdentity || expected === item.startIdentity; }; - const parents = new Set([child.pid]); + // 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); @@ -389,7 +394,7 @@ export async function refreshProcessTree(child, treeState, options = {}) { while (changed) { changed = false; for (const item of processes) { - if ((item.processGroupId === child.pid || parents.has(item.parentPid)) && + if (((leaderIsOriginal && item.processGroupId === child.pid) || parents.has(item.parentPid)) && !parents.has(item.pid)) { parents.add(item.pid); treeState.knownPids.add(item.pid); @@ -512,6 +517,9 @@ export async function signalProcessTree(child, signal, treeState, { 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 (item && expected && item.startIdentity && expected !== item.startIdentity) continue; try { killOne(pid, signal); } catch (error) { if (error.code !== "ESRCH") throw error; } diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 4d724ee..767e8b3 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -185,7 +185,15 @@ async function loadBackends() { const backends = parsed && typeof parsed === "object" && parsed.backends && typeof parsed.backends === "object" ? parsed.backends : FALLBACK_BACKENDS; - if (Object.keys(backends).length > 0) return backends; + if (Object.keys(backends).length > 0) { + const configDirectory = path.dirname(file); + return Object.fromEntries(Object.entries(backends).map(([name, spec]) => { + if (!spec || typeof spec.command !== "string" || !/[\\/]/u.test(spec.command)) { + return [name, spec]; + } + return [name, { ...spec, command: path.resolve(configDirectory, spec.command) }]; + })); + } } catch { // fall through to the next candidate } @@ -203,13 +211,14 @@ function substituteArgs(template, task, session) { // 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() { +function capture(binary = false) { let chunks = []; let length = 0; let truncated = false; return { push(chunk) { - if (typeof chunk !== "string" || chunk.length === 0) return; + 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) { @@ -218,16 +227,17 @@ function capture() { truncated = true; } }, - text() { return chunks.join(""); }, + value() { return binary ? Buffer.concat(chunks, length) : chunks.join(""); }, truncated() { return truncated; }, }; } 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: "", stderr: "", exitCode: null, timedOut: false, killed: false, + 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, @@ -256,7 +266,7 @@ async function runCommand(command, args, options = {}) { windowsHide: true, stdio: [options.stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"], }); - const stdoutBuf = capture(); + const stdoutBuf = capture(binaryStdout); const stderrBuf = capture(); let settled = false; let spawnError = null; @@ -290,8 +300,8 @@ async function runCommand(command, args, options = {}) { clearTimeout(timer); if (treeRefreshTimer) clearInterval(treeRefreshTimer); resolve({ - stdout: stdoutBuf.text(), - stderr: stderrBuf.text(), + stdout: stdoutBuf.value(), + stderr: stderrBuf.value(), exitCode, timedOut, killed, @@ -357,7 +367,7 @@ async function runCommand(command, args, options = {}) { const timer = setTimeout(() => { void terminate("timeout"); }, timeoutMs); - child.stdout.setEncoding("utf8"); + 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); }); @@ -369,8 +379,8 @@ async function runCommand(command, args, options = {}) { clearTimeout(timer); if (treeRefreshTimer) clearInterval(treeRefreshTimer); resolve({ - stdout: stdoutBuf.text(), - stderr: stderrBuf.text(), + stdout: stdoutBuf.value(), + stderr: stderrBuf.value(), exitCode: null, timedOut, killed, @@ -446,7 +456,14 @@ async function validateWorkspace(workspacePath) { throw new Error("workspacePath does not exist: " + resolved); } if (!stats.isDirectory()) throw new Error("workspacePath must be a directory: " + resolved); - return 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 realpath(resolved); + } catch (error) { + throw new Error("cannot canonicalize workspacePath: " + error.message); + } } async function requireGitRepo(workspacePath, options = {}) { @@ -494,7 +511,10 @@ async function gitCommonDirectory(workspacePath, options = {}) { function repositoryLockKey(gitCommonDir) { const normalized = path.normalize(gitCommonDir); - return "git-common-dir:" + (process.platform === "win32" ? normalized.toLowerCase() : normalized); + // 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; } function snapshotFailure(label, result) { @@ -514,6 +534,7 @@ async function runGitCommand(args, { cancel = null, deadline = null, stdinText, + binaryStdout = false, timeoutMs = GIT_TIMEOUT_MS, } = {}) { if (cancel?.cancelled) throw new OperationCancelledError("operation cancelled by client"); @@ -523,6 +544,7 @@ async function runGitCommand(args, { const result = await runCommand("git", args, { cwd, stdinText, + binaryStdout, timeoutMs: Math.max(1, Math.min(timeoutMs, remaining)), killGraceMs: 1_000, shouldCancel: () => Boolean(cancel?.cancelled), @@ -547,12 +569,12 @@ const CONCURRENT_LEASE_STALE_MS = 30_000; async function gitSnapshot(worktreeRoot, options = {}) { const ownLockRef = typeof options.ownLockRef === "string" ? options.ownLockRef : null; const jobs = [ - ["git status --short", "status", ["status", "--short", "--untracked-files=all"]], - ["git diff --stat", "diffStat", ["diff", "--stat"]], - ["git diff --name-only -z", "diffNames", ["diff", "--name-only", "-z"]], - ["git diff --cached --stat", "cachedDiffStat", ["diff", "--cached", "--stat"]], - ["git diff --cached --name-only -z", "cachedDiffNames", ["diff", "--cached", "--name-only", "-z"]], - ["git ls-files --others --exclude-standard -z", "untracked", ["ls-files", "--others", "--exclude-standard", "-z"]], + ["git status --short", "status", ["status", "--short", "--untracked-files=all", "--ignore-submodules=none"]], + ["git diff --stat", "diffStat", ["diff", "--ignore-submodules=none", "--stat"]], + ["git diff --name-only -z", "diffNames", ["diff", "--ignore-submodules=none", "--name-only", "-z"], false, 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 for-each-ref", "refs", ["for-each-ref", "--format=%(refname)%09%(objectname)", "refs"]], ]; @@ -560,7 +582,9 @@ async function gitSnapshot(worktreeRoot, options = {}) { // processes can race for .git/index.lock on the same repository. const results = []; for (const job of jobs) { - results.push(await runGitCommand(job[2], { cwd: worktreeRoot, ...options })); + results.push(await runGitCommand(job[2], { + cwd: worktreeRoot, ...options, binaryStdout: job[4] === true, + })); } const failures = []; const out = {}; @@ -578,7 +602,30 @@ async function gitSnapshot(worktreeRoot, options = {}) { throw new Error("git snapshot unreliable: " + failures.join("; ")); } const seen = new Set(); - const nulNames = (value) => String(value ?? "").split("\0").filter((name) => name.length > 0); + 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), @@ -630,7 +677,9 @@ async function gitSnapshot(worktreeRoot, options = {}) { } catch { /* malformed owner blob: ignore for disclosure */ } } return { - statusShort: String(out.status ?? "").trim(), + // 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(), @@ -863,18 +912,18 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { attributedCommits.set(oid, { oid, subject, labels: new Set(labels) }); } } - // For a ref that did not exist before, diff from its best common ancestor - // with the pre-delegation state, not from the original HEAD: a new branch - // created from another divergent branch would otherwise attribute every - // pre-existing difference between those branches to the worker. + // Diff from the closest ancestral pre-run tip, even for an existing ref. + // A force update can move a ref onto a pre-existing descendant lineage; + // using its older (but still ancestral) tip would attribute that lineage's + // already-existing changes to the worker. let base; const previousTarget = beforeOid ? await peelCommitish(worktreeRoot, beforeOid, cache, options) : null; - if (previousTarget) { - base = previousTarget; - } else if (baselineCommits.length > 0) { + if (baselineCommits.length > 0) { base = await closestExistingBase(worktreeRoot, target, baselineCommits, options) ?? await emptyTree(); + } else if (previousTarget) { + base = previousTarget; } else { base = before.head || await emptyTree(); } diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs index f5abd66..f5b94e3 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs @@ -101,6 +101,22 @@ if (spec.branchRoundTrip) { 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); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs index 29f2e4b..0e4fbba 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs @@ -230,6 +230,47 @@ test("the process group is signaled only while its leader identity is original", 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("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]), diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index bf2663b..5499cb8 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { access, chmod, copyFile, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { access, chmod, copyFile, mkdir, mkdtemp, readFile, realpath, rm, symlink, unlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { createInterface } from "node:readline"; @@ -34,7 +34,7 @@ async function canonicalGitCommonDirectory(workspace) { function repositoryStatePaths(canonicalGitCommonDir) { const normalized = path.normalize(canonicalGitCommonDir); - const key = "git-common-dir:" + (process.platform === "win32" ? normalized.toLowerCase() : normalized); + const key = "git-common-dir:" + normalized; const digest = createHash("sha256").update(key).digest("hex"); const root = currentUserLockRoot(); return { @@ -204,6 +204,43 @@ test("dirty checks include untracked files even when Git config hides them", asy 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("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"); @@ -227,6 +264,65 @@ test("canonical Git worktree locking serializes root and symlink paths", async ( 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); @@ -316,7 +412,7 @@ test("losing a Git-ref lease never strands the local FIFO gate", async (context) const { tempRoot, workspace, client } = await makeHarness(context); const canonicalRoot = await canonicalGitCommonDirectory(workspace); const normalized = path.normalize(canonicalRoot); - const key = "git-common-dir:" + (process.platform === "win32" ? normalized.toLowerCase() : normalized); + const key = "git-common-dir:" + normalized; const ref = workspaceLockRef(key); const eventFile = path.join(tempRoot, "lost-lock-events.jsonl"); const first = client.request("tools/call", taskArguments(workspace, { @@ -385,7 +481,7 @@ test("unconfirmed termination after lease loss quarantines delegation and status ({ quarantinePath } = repositoryStatePaths(canonicalRoot)); context.after(() => rm(quarantinePath, { force: true })); const normalized = path.normalize(canonicalRoot); - const key = "git-common-dir:" + normalized.toLowerCase(); + const key = "git-common-dir:" + normalized; ref = workspaceLockRef(key); const delegated = client.request("tools/call", taskArguments(workspace, { name: "unconfirmed-tree", eventFile, delayMs: 60_000, @@ -695,6 +791,20 @@ test("changedFiles preserves unusual names and scans from the worktree root", as assert.equal(status.result.structuredContent.worktreeRoot, await realpath(workspace)); }); +test("changedFiles losslessly represents non-UTF-8 Git path bytes", async (context) => { + if (process.platform === "win32") return; // Windows filenames are Unicode, not arbitrary byte strings + 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, { @@ -856,6 +966,30 @@ test("a ref moved from a blob to a new commit uses a commit-safe diff base", asy assert.doesNotMatch(out.commits.diffStat, new RegExp(blobOid.trim(), "u")); }); +test("a force-moved ref diffs from an ancestral pre-run baseline", async (context) => { + const { workspace, client } = await makeHarness(context); + await execFileAsync("git", ["branch", "force-target"], { 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"); +}); + 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"); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs index 88b83b3..2c88114 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFile, execFileSync } from "node:child_process"; import { writeFileSync } from "node:fs"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { access, chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -351,6 +351,57 @@ test("worker state updates honour the delegation cancellation and deadline", asy await assert.rejects(execFileAsync("git", ["rev-parse", "--verify", workspaceLockRef(key)], { cwd: repo }), /Command failed/u); }); +test("initial acquisition CAS obeys cancellation while a Git hook blocks", async (context) => { + if (process.platform === "win32") return; // executable hook setup is POSIX-specific + 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 listeners = new Set(); + let resolveCancelled; + const cancel = { + cancelled: false, + promise: new Promise((resolve) => { resolveCancelled = resolve; }), + subscribe(listener) { + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + cancel() { + this.cancelled = true; + resolveCancelled(); + for (const listener of [...listeners]) listener(); + }, + }; + const acquisition = tryAcquireGitWorkspaceLock({ + cwd: repo, key: "git-worktree:" + repo, cancel, heartbeatMs: 60_000, + }); + const readyDeadline = Date.now() + 3_000; + while (true) { + try { await access(ready); break; } + catch { + if (Date.now() >= readyDeadline) throw new Error("reference-transaction hook did not start"); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + } + const cancelledAt = Date.now(); + cancel.cancel(); + await assert.rejects(acquisition, WorkspaceLockCancelledError); + assert.ok(Date.now() - cancelledAt < 1_500, + "cancellation must interrupt the update-ref CAS instead of waiting for the hook timeout"); + await writeFile(release, "release\n"); +}); + test("quarantined leases are reclaimable after the operator clears the marker", async (context) => { const repo = await makeRepo(context); const key = "git-worktree:" + repo; diff --git a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs index 332fa88..0e3a97b 100644 --- a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs @@ -212,7 +212,7 @@ async function compareAndSwap(cwd, ref, newOid, expectedOid, options = {}) { returnOnTimeout: true, }); if (result.exitCode === 0) return true; - const observedOid = await readRefOid(cwd, ref); + 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; @@ -466,10 +466,10 @@ export async function tryAcquireGitWorkspaceLock({ if (!current) { const zeroOid = "0".repeat(newOid.length); checkInterrupted(cancel, deadline); - acquired = await compareAndSwap(cwd, ref, newOid, zeroOid); + acquired = await compareAndSwap(cwd, ref, newOid, zeroOid, { cancel, deadline }); } else { checkInterrupted(cancel, deadline); - acquired = await compareAndSwap(cwd, ref, newOid, current.oid); + acquired = await compareAndSwap(cwd, ref, newOid, current.oid, { cancel, deadline }); } if (!acquired) return { acquired: false, reason: "contended" }; locallyAbandonedRefs.delete(localRefKey); From b17c9291b581b53740a543fc19848106ef02e02a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 23:08:26 +0800 Subject: [PATCH 25/40] fix(cli-agent-bridge): isolate lock metadata from mirrors --- plugins/Hylouis233/cli-agent-bridge/README.md | 23 ++-- .../cli-agent-bridge/ps1-runner.ps1 | 9 +- .../Hylouis233/cli-agent-bridge/server.mjs | 85 ++++++++++++-- .../skills/cli-agent-bridge/SKILL.md | 9 +- .../cli-agent-bridge/tests/fake-backend.mjs | 6 + .../cli-agent-bridge/tests/server.test.mjs | 108 +++++++++++++++--- 6 files changed, 199 insertions(+), 41 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 86943a6..283ccf7 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -117,22 +117,25 @@ you already obtained a valid ID from that backend outside this Plugin. - 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. A stale idle lock is reclaimed only when its same-host owner is positively + 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. 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 cannot reconstruct descendants that escaped into another POSIX session from the - recorded worker PID alone, so inspect leftover processes and clear those hidden refs manually. + recorded worker PID alone, so inspect leftover processes 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 worktree and index unchanged, but it requires writable Git object/ref metadata: - each acquisition writes an owner blob and temporarily updates a hidden ref. Repository - reference-transaction hooks can observe or reject those updates, and released owner blobs remain - unreachable until normal Git garbage collection. For that reason workspace_status is not marked +- Locking leaves the target repository refs, worktree, and index unchanged, but it requires writable + metadata in the private bare lock store. Each acquisition writes an owner blob and temporarily + updates a coordination ref there; released owner blobs remain unreachable until that store's + normal Git garbage collection. For that reason 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. A lightweight ancestry monitor records descendants that create a new POSIX @@ -148,9 +151,9 @@ you already obtained a valid ID from that backend outside this Plugin. process refuses further delegation until an operator checks for leftovers and deliberately removes the reported quarantinePath - removing that marker also authorizes the next delegation to reclaim the quarantined lease. If the bridge crashes mid-run, a lease recording a running - worker still cannot be reclaimed automatically (descendant liveness cannot be proven); delete - the hidden lock ref recorded in the quarantine marker (or run `git update-ref -d` on the ref - under `refs/cli-agent-bridge/workspace-locks/`) after checking for leftover processes. The + worker still cannot be reclaimed automatically (descendant liveness cannot be proven); after + checking for leftover processes, delete the lock ref recorded in the quarantine marker with + `git --git-dir=/cli-agent-bridge-lock-store.git update-ref -d `. The quarantine marker itself lives in a current-user-scoped OS temporary directory. On Linux, zombie-only tracked trees count as terminated; zombies cannot edit the workspace and may otherwise persist when container PID 1 does not reap them. diff --git a/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 b/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 index 93bba18..71f5462 100644 --- a/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 +++ b/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 @@ -12,12 +12,13 @@ try { $resolved = Get-Command -Name $Command -CommandType Application, ExternalScript -ErrorAction Stop $global:LASTEXITCODE = $null & $resolved.Source @rest - if (-not $?) { exit 1 } - if ($null -eq $LASTEXITCODE) { exit 0 } - exit [int]$LASTEXITCODE + $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 index 767e8b3..49bd1f9 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -444,15 +444,55 @@ function tail(text, count) { return text.length > count ? text.slice(-count) : text; } -async function validateWorkspace(workspacePath) { +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")); + } + 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(operation).then( + (value) => finish(resolve, value), + (error) => finish(reject, error), + ); + }); +} + +async function validateWorkspace(workspacePath, options = {}) { if (typeof workspacePath !== "string" || !workspacePath.trim()) { throw new Error("workspacePath must be a non-empty string"); } const resolved = path.resolve(workspacePath); let stats; try { - stats = await stat(resolved); + 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 Error("workspacePath does not exist: " + resolved); } if (!stats.isDirectory()) throw new Error("workspacePath must be a directory: " + resolved); @@ -460,8 +500,9 @@ async function validateWorkspace(workspacePath) { // 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 realpath(resolved); + return await interruptibleFilesystemOperation(realpath(resolved), options); } catch (error) { + if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError) throw error; throw new Error("cannot canonicalize workspacePath: " + error.message); } } @@ -517,6 +558,26 @@ function repositoryLockKey(gitCommonDir) { return "git-common-dir:" + normalized; } +const WORKSPACE_LOCK_STORE_NAME = "cli-agent-bridge-lock-store.git"; +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 result = await runGitCommand(["init", "--bare", "--quiet", storeRoot], { + cwd: gitCommonDir, ...options, + }); + const failure = snapshotFailure("git init --bare workspace lock store", result); + if (failure) throw new Error("cannot initialize workspace lock store: " + failure); + } + return await interruptibleFilesystemOperation(realpath(storeRoot), options); +} + function snapshotFailure(label, result) { if (result.timedOut) return label + " timed out"; if (result.stdoutTruncated || result.stderrTruncated) { @@ -821,7 +882,7 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { ].filter(Boolean)); const externalRefChanges = []; for (const change of refsChanged) { - if (change.ref.startsWith("refs/remotes/")) { + if (change.ref.startsWith("refs/remotes/") || change.ref.startsWith("refs/prefetch/")) { externalRefChanges.push(change); continue; } @@ -1109,7 +1170,7 @@ async function serverProcessStartIdentity() { // server processes without a read-then-unlink stale-owner race. const workspaceLocks = new Map(); const quarantinedWorkspaces = new Set(); -async function withWorkspaceLock(key, worktreeRoot, fn, { +async function withWorkspaceLock(key, lockStoreRoot, fn, { cancel = null, deadline = null, onCancelled = null, @@ -1154,7 +1215,7 @@ async function withWorkspaceLock(key, worktreeRoot, fn, { } try { lease = await acquireGitWorkspaceLock({ - cwd: worktreeRoot, + cwd: lockStoreRoot, key, cancel, deadline, @@ -1334,15 +1395,17 @@ async function delegateTask(rawArgs, cancel) { let workspacePath = ""; let worktreeRoot = ""; let gitCommonDir = ""; + let lockStoreRoot = ""; try { if (cancel?.cancelled) { return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); } - workspacePath = await validateWorkspace(rawArgs.workspacePath); + 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 }); + lockStoreRoot = await ensureWorkspaceLockStore(gitCommonDir, { cancel, deadline }); } catch (error) { if (error instanceof OperationCancelledError) { return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); @@ -1364,7 +1427,7 @@ async function delegateTask(rawArgs, cancel) { return quarantinedDelegation({ backend, workspacePath, worktreeRoot, spec }, existingQuarantine); } let observedQuarantine = null; - return await withWorkspaceLock(lockKey, worktreeRoot, async (workspaceLease) => { + return await withWorkspaceLock(lockKey, lockStoreRoot, async (workspaceLease) => { const sharedQuarantine = await readWorkspaceQuarantine(lockKey); if (quarantinedWorkspaces.has(lockKey) || sharedQuarantine) { return quarantinedDelegation({ backend, workspacePath, worktreeRoot, spec }, sharedQuarantine); @@ -1796,14 +1859,16 @@ async function handleMessage(message) { let workspacePath = ""; let worktreeRoot = ""; let gitCommonDir = ""; + let lockStoreRoot = ""; try { - workspacePath = await validateWorkspace(args.workspacePath); + 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 }); + lockStoreRoot = await ensureWorkspaceLockStore(gitCommonDir, { cancel }); } catch (error) { if (error instanceof OperationCancelledError) { return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); @@ -1819,7 +1884,7 @@ async function handleMessage(message) { ); } let observedQuarantine = null; - return await withWorkspaceLock(lockKey, worktreeRoot, async () => { + return await withWorkspaceLock(lockKey, lockStoreRoot, async () => { const sharedQuarantine = await readWorkspaceQuarantine(lockKey); if (quarantinedWorkspaces.has(lockKey) || sharedQuarantine) { return quarantinedWorkspaceStatus( 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 index 253d85a..0b1e9c1 100644 --- a/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -67,12 +67,13 @@ inside the target git repository, and their results come back as a git diff for 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 worktree files, but cross-process serialization temporarily writes - a hidden Git lock ref and owner blob. Git metadata must be writable, and repository - reference-transaction hooks may observe or reject the lock update. +- 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 that hidden ref. A lease + 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 removes the quarantine marker. ## Notes diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs index f5b94e3..22edf7b 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs @@ -56,6 +56,8 @@ if (spec.branchRoundTrip) { 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]); } @@ -65,6 +67,10 @@ if (spec.branchRoundTrip) { execFileSync("git", ["commit", "-m", spec.commitMessage ?? "worker commit after fetch"]); execFileSync("git", ["checkout", original]); 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"); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index 5499cb8..d2df71f 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -32,6 +32,10 @@ async function canonicalGitCommonDirectory(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"); +} + function repositoryStatePaths(canonicalGitCommonDir) { const normalized = path.normalize(canonicalGitCommonDir); const key = "git-common-dir:" + normalized; @@ -414,6 +418,7 @@ test("losing a Git-ref lease never strands the local FIFO gate", async (context) const normalized = path.normalize(canonicalRoot); const key = "git-common-dir:" + normalized; 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, @@ -423,8 +428,8 @@ test("losing a Git-ref lease never strands the local FIFO gate", async (context) )); await waitFor(async () => { try { - const { stdout: oid } = await execFileAsync("git", ["rev-parse", "--verify", ref], { cwd: workspace }); - const { stdout: blob } = await execFileAsync("git", ["cat-file", "blob", oid.trim()], { cwd: workspace }); + 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; @@ -433,12 +438,12 @@ test("losing a Git-ref lease never strands the local FIFO gate", async (context) 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: workspace }); + 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: workspace }); + await execFileAsync("git", ["update-ref", ref, replacementOid], { cwd: lockStore }); context.after(async () => { - try { await execFileAsync("git", ["update-ref", "-d", ref, replacementOid], { cwd: workspace }); } catch { /* already gone */ } + try { await execFileAsync("git", ["update-ref", "-d", ref, replacementOid], { cwd: lockStore }); } catch { /* already gone */ } }); const firstResponse = await first; @@ -447,7 +452,7 @@ test("losing a Git-ref lease never strands the local FIFO gate", async (context) 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: workspace }); + 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, @@ -474,6 +479,7 @@ test("unconfirmed termination after lease loss quarantines delegation and status let workerPid = null; let replacementOid = null; let ref = null; + let lockStore = null; let quarantinePath = null; try { await client.initialize(); @@ -483,6 +489,7 @@ test("unconfirmed termination after lease loss quarantines delegation and status const normalized = path.normalize(canonicalRoot); const key = "git-common-dir:" + normalized; ref = workspaceLockRef(key); + lockStore = await coordinationLockStore(workspace); const delegated = client.request("tools/call", taskArguments(workspace, { name: "unconfirmed-tree", eventFile, delayMs: 60_000, }), 151); @@ -495,8 +502,8 @@ test("unconfirmed termination after lease loss quarantines delegation and status }); await waitFor(async () => { try { - const { stdout: oid } = await execFileAsync("git", ["rev-parse", "--verify", ref], { cwd: workspace }); - const { stdout: blob } = await execFileAsync("git", ["cat-file", "blob", oid.trim()], { cwd: workspace }); + 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; @@ -504,13 +511,13 @@ test("unconfirmed termination after lease loss quarantines delegation and status }); 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: workspace }); + 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: workspace }); + await execFileAsync("git", ["update-ref", ref, replacementOid], { cwd: lockStore }); const failed = await delegated; assert.match(failed.error?.message ?? "", /workspace lock ownership changed/iu); @@ -529,15 +536,15 @@ test("unconfirmed termination after lease loss quarantines delegation and status await execFileAsync(realTaskkill, ["/PID", String(workerPid), "/T", "/F"]); workerPid = null; await rm(quarantinePath, { force: true }); - await execFileAsync("git", ["update-ref", "-d", ref, replacementOid], { cwd: workspace }); + 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) { - try { await execFileAsync("git", ["update-ref", "-d", ref, replacementOid], { cwd: workspace }); } catch { /* already gone */ } + 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 */ } @@ -633,6 +640,27 @@ test("cancellation interrupts initial Git repository discovery", { } }); +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("cancellation terminates descendants before returning", async (context) => { const { tempRoot, workspace, client } = await makeHarness(context); const eventFile = path.join(tempRoot, "events.jsonl"); @@ -1153,6 +1181,43 @@ test("a fetched tag tip is an external baseline for later worker commits", async assert.match(out.commits.diffStat, /worker-after-tag\.txt/u); }); +test("prefetch refs are external baselines for later worker 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, 1, out.commits.log); + assert.match(out.commits.log, /worker commit after prefetch/u); + assert.doesNotMatch(out.commits.log, /fetched upstream commit/u); + assert.match(out.commits.log, /refs\/prefetch\/remotes\/origin\/main moved to externally sourced history/u); + assert.doesNotMatch(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, + })); + assert.equal(response.result.structuredContent.ok, true, response.result.structuredContent.error); + 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 }); }); @@ -1225,6 +1290,23 @@ test("PowerShell shim runner fails closed for a missing backend", { 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); From dd7d4b4c14cfbc9556610ebe4113d17b6f247430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 23:29:06 +0800 Subject: [PATCH 26/40] test(cli-agent-bridge): gate Linux-only fixtures --- plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs | 7 +++++-- .../cli-agent-bridge/tests/workspace-lock.test.mjs | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index d2df71f..c7701a1 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -819,8 +819,11 @@ test("changedFiles preserves unusual names and scans from the worktree root", as assert.equal(status.result.structuredContent.worktreeRoot, await realpath(workspace)); }); -test("changedFiles losslessly represents non-UTF-8 Git path bytes", async (context) => { - if (process.platform === "win32") return; // Windows filenames are Unicode, not arbitrary byte strings +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]); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs index 2c88114..bdcfadd 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs @@ -351,8 +351,11 @@ test("worker state updates honour the delegation cancellation and deadline", asy await assert.rejects(execFileAsync("git", ["rev-parse", "--verify", workspaceLockRef(key)], { cwd: repo }), /Command failed/u); }); -test("initial acquisition CAS obeys cancellation while a Git hook blocks", async (context) => { - if (process.platform === "win32") return; // executable hook setup is POSIX-specific +test("initial acquisition CAS obeys cancellation while a Git hook blocks", { + // This fixture depends on Linux's executable-hook and process interruption + // semantics. macOS Git installations may disable or sandbox this hook path. + 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"); From a1ecd0a21b02b83c0165a91823d4e70112e8c12e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Sun, 16 Aug 2026 23:41:35 +0800 Subject: [PATCH 27/40] fix(cli-agent-bridge): close latest review findings --- plugins/Hylouis233/cli-agent-bridge/README.md | 11 ++++-- .../Hylouis233/cli-agent-bridge/server.mjs | 37 ++++++++++++++++--- .../cli-agent-bridge/tests/server.test.mjs | 26 +++++++++++++ 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 283ccf7..89bf554 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -151,9 +151,14 @@ you already obtained a valid ID from that backend outside this Plugin. process refuses further delegation until an operator checks for leftovers and deliberately removes the reported quarantinePath - removing that marker also authorizes the next delegation to reclaim the quarantined lease. If the bridge crashes mid-run, a lease recording a running - worker still cannot be reclaimed automatically (descendant liveness cannot be proven); after - checking for leftover processes, delete the lock ref recorded in the quarantine marker with - `git --git-dir=/cli-agent-bridge-lock-store.git update-ref -d `. The + 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 in a current-user-scoped OS temporary directory. On Linux, zombie-only tracked trees count as terminated; zombies cannot edit the workspace and may otherwise persist when container PID 1 does not reap them. diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 49bd1f9..969b1e5 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -528,7 +528,7 @@ async function gitWorktreeRoot(workspacePath, options = {}) { throw new Error("cannot identify Git worktree root: " + (failure || "empty output")); } try { - return await realpath(output); + return await interruptibleFilesystemOperation(realpath(output), options); } catch (error) { throw new Error("cannot canonicalize Git worktree root: " + error.message); } @@ -544,7 +544,9 @@ async function gitCommonDirectory(workspacePath, options = {}) { throw new Error("cannot identify Git common directory: " + (failure || "empty output")); } try { - return await realpath(path.resolve(workspacePath, output)); + return await interruptibleFilesystemOperation( + realpath(path.resolve(workspacePath, output)), options, + ); } catch (error) { throw new Error("cannot canonicalize Git common directory: " + error.message); } @@ -637,6 +639,7 @@ async function gitSnapshot(worktreeRoot, options = {}) { ["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"]], ]; // Run serially: status/diff may both refresh the index, so concurrent Git @@ -744,6 +747,7 @@ async function gitSnapshot(worktreeRoot, options = {}) { diffStat, changedFiles, head: String(out.head ?? "").trim(), + headRef: String(out.headRef ?? "").replace(/\r?\n$/u, ""), refs, concurrentDelegations, }; @@ -836,7 +840,7 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { const afterOid = after.refs?.[ref] ?? ""; return beforeOid === afterOid ? [] : [{ ref, before: beforeOid, after: afterOid }]; }); - if (before.head === after.head && refsChanged.length === 0) return null; + if (before.head === after.head && before.headRef === after.headRef && refsChanged.length === 0) return null; let emptyTreeId = ""; async function emptyTree() { @@ -921,6 +925,12 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { const movementLogs = externalRefChanges.map((change) => change.ref + " moved to externally sourced history; excluded from worker-created commits", ); + 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(); @@ -1474,7 +1484,18 @@ async function delegateTask(rawArgs, cancel) { } let template; - if (typeof rawArgs.resumeSessionId === "string" && rawArgs.resumeSessionId.trim() && Array.isArray(spec.resumeArgs)) { + 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; @@ -1658,8 +1679,10 @@ async function delegateTask(rawArgs, cancel) { error = "backend process tree could not be confirmed terminated; the shared workspace quarantine remains until an operator checks for leftovers and removes quarantinePath"; } else if (cancel && cancel.cancelled) { error = "delegation cancelled by client; post-run snapshot may be unavailable"; - } else if (result.timedOut || postRunDeadlineExceeded) { + } 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) { @@ -1675,7 +1698,8 @@ async function delegateTask(rawArgs, cancel) { workspacePath, worktreeRoot, exitCode: result.exitCode, - timedOut: result.timedOut || postRunDeadlineExceeded, + timedOut: result.timedOut, + postRunDeadlineExceeded, killed: result.killed, cancelled: Boolean(cancel && cancel.cancelled), orphanedProcesses: result.orphanedProcesses, @@ -1720,6 +1744,7 @@ function textResult(header, obj) { 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) { diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index c7701a1..2272a9d 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -194,6 +194,17 @@ test("Codex templates delimit option-looking task text", async () => { assert.match(source, /resumeArgs: \["exec", "resume", "", "--", ""\]/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 }); @@ -963,6 +974,21 @@ test("checking out a pre-existing divergent branch is not reported as worker com "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, { From 6f158b2d9df7603ab31295a6a8ba989057e619ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Mon, 17 Aug 2026 00:09:49 +0800 Subject: [PATCH 28/40] fix(cli-agent-bridge): harden shared lock lifecycle --- plugins/Hylouis233/cli-agent-bridge/README.md | 14 ++- .../cli-agent-bridge/process-tree.mjs | 6 +- .../Hylouis233/cli-agent-bridge/server.mjs | 29 ++++- .../cli-agent-bridge/tests/server.test.mjs | 47 ++++++++ .../tests/workspace-lock.test.mjs | 26 +++- .../cli-agent-bridge/workspace-lock.mjs | 113 ++++++++++++++---- 6 files changed, 198 insertions(+), 37 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 89bf554..b1d3cd1 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -133,9 +133,13 @@ you already obtained a valid ID from that backend outside this Plugin. 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. Each acquisition writes an owner blob and temporarily - updates a coordination ref there; released owner blobs remain unreachable until that store's - normal Git garbage collection. For that reason workspace_status is not marked + 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. Periodic ownership checks read that ref + without manufacturing new heartbeat blobs, and 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. A lightweight ancestry monitor records descendants that create a new POSIX @@ -145,7 +149,9 @@ you already obtained a valid ID from that backend outside this Plugin. leader identity still matches. On Linux, descendants also inherit a per-run environment marker; if the parent exits before ancestry polling, the close path uses a bounded observation grace and marker scans to recover children that become visible just after the leader exits, without - continuously scanning all of `/proc`. If termination cannot be + continuously scanning all of `/proc`. Repository discovery and snapshot Git commands use the same + containment, so a hook or helper cannot leave an untracked descendant behind after the lease is + released. If termination cannot be confirmed, the bridge writes a shared quarantine marker, moves its lease into the recoverable `quarantined` state, and every bridge process refuses further delegation until an operator checks for leftovers and deliberately diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs index 8c68dbe..e5be520 100644 --- a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs @@ -441,7 +441,11 @@ export async function isProcessTreeAlive(child, treeState, { if (platform === "linux" && treeState.runMarker && !processes.some((item) => item.pid === child.pid) && treeState.markerObservationComplete !== true) { - const observationDeadline = Date.now() + MARKER_OBSERVATION_GRACE_MS; + 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 }); diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 969b1e5..18b711d 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -281,6 +281,7 @@ async function runCommand(command, args, options = {}) { knownPids: new Set(Number.isInteger(child.pid) ? [child.pid] : []), knownStarts: new Map(), runMarker: linuxRunMarker, + markerObservationGraceMs: options.markerObservationGraceMs, }; let treeRefreshPromise = null; let treeRefreshTimer = null; @@ -571,7 +572,20 @@ async function ensureWorkspaceLockStore(gitCommonDir, options = {}) { if (error.code !== "ENOENT") throw error; } if (!initialized) { - const result = await runGitCommand(["init", "--bare", "--quiet", storeRoot], { + 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 initArgs = ["init", "--bare", "--quiet"]; + if (shared) initArgs.push("--shared=" + shared); + initArgs.push(storeRoot); + const result = await runGitCommand(initArgs, { cwd: gitCommonDir, ...options, }); const failure = snapshotFailure("git init --bare workspace lock store", result); @@ -608,6 +622,13 @@ async function runGitCommand(args, { cwd, stdinText, binaryStdout, + manageProcessTree: true, + // Git hooks/helpers are polled while Git is alive and receive one final + // marker scan after it exits. Do not add the worker-oriented 500 ms late- + // visibility grace to every merge-base/ref query: attribution can issue + // hundreds of these commands. A marked descendant found by the final scan + // is still terminated and drained before this call returns. + markerObservationGraceMs: 0, timeoutMs: Math.max(1, Math.min(timeoutMs, remaining)), killGraceMs: 1_000, shouldCancel: () => Boolean(cancel?.cancelled), @@ -1249,10 +1270,8 @@ async function withWorkspaceLock(key, lockStoreRoot, fn, { try { await lease.release(); } catch (error) { - // Register the exact leftover OID before releasing the local FIFO - // gate. This permits only this server's next local holder to repair - // a failed delete, including starting/running owner records. - lease.allowLocalRecovery(); + // release() persisted exact-owner recovery authorization in the + // shared lock store before attempting deletion. throw error; } } diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index 2272a9d..b6afe6f 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -228,6 +228,53 @@ test("porcelain status preserves the unstaged first-column space", async (contex assert.match(response.result.structuredContent.git.statusShort, /^ M baseline\.txt$/u); }); +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); +}); + +test("Git snapshot commands terminate escaped hook descendants", { + skip: process.platform !== "linux" ? "Linux /proc marker containment 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, client.stderr); + await access(ready); + await new Promise((resolve) => setTimeout(resolve, 1_400)); + await assert.rejects(access(survivor), /ENOENT/u, + "the fsmonitor descendant must be dead before the workspace lease is released"); +}); + test("dirty checks override submodule ignore configuration", async (context) => { const { tempRoot, workspace, client } = await makeHarness(context); const source = path.join(tempRoot, "submodule-source"); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs index bdcfadd..e52a8de 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs @@ -206,7 +206,7 @@ test("an update-ref infrastructure failure is not misclassified as contention", ); }); -test("a failed release can be recovered by the next local holder in every completed state", async (context) => { +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; @@ -220,7 +220,6 @@ test("a failed release can be recovered by the next local holder in every comple const blocker = refPath + ".lock"; await writeFile(blocker, "intentional release failure\n"); await assert.rejects(first.lease.release(), /cannot delete workspace lock ref/iu); - first.lease.allowLocalRecovery(); await rm(blocker, { force: true }); const second = await tryAcquireGitWorkspaceLock({ cwd: repo, key, heartbeatMs: 60_000 }); @@ -245,15 +244,32 @@ test("a failed release in one linked worktree is recoverable from another", asyn 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); - first.lease.allowLocalRecovery(); await rm(blocker, { force: true }); - const second = await tryAcquireGitWorkspaceLock({ cwd: linked, key, heartbeatMs: 60_000 }); + // 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 abandoned OID is visible from every linked worktree"); + "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, 1, + "read-only heartbeat probes must leave only the current owner blob"); + await result.lease.release(); +}); + test("post-CAS cancellation remains recoverable when its compensating delete fails", async (context) => { const repo = await makeRepo(context); const key = "git-worktree:" + repo; diff --git a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs index 0e3a97b..85606c2 100644 --- a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs @@ -5,6 +5,7 @@ import os from "node:os"; export const WORKSPACE_LOCK_REF_PREFIX = "refs/cli-agent-bridge/workspace-locks/"; const WORKSPACE_HISTORY_REF_SUFFIX = ".history"; +const WORKSPACE_RECOVERY_REF_SUFFIX = ".recovery"; const DEFAULT_STALE_MS = 30_000; const DEFAULT_HEARTBEAT_MS = 5_000; const DEFAULT_POLL_MS = 100; @@ -13,13 +14,6 @@ const CAPTURE_LIMIT = 64_000; const PIPE_DRAIN_MS = 100; const RELEASE_RETRY_MS = 50; const RELEASE_ATTEMPTS = 3; -// A failed release may leave this process's exact owner blob installed. The -// server registers that OID while its in-process FIFO gate is still held, so -// only the next local holder may replace it after the failed request unwinds. -// The ref already includes the repository-scoped key; do not add a worktree -// cwd, because linked worktrees share this same lease. -const locallyAbandonedRefs = new Map(); - export class WorkspaceLockCancelledError extends Error {} export class WorkspaceLockDeadlineError extends Error {} @@ -67,6 +61,50 @@ async function writeRunHistory(cwd, lockRef, owner) { } } +async function writeRecoveryAuthorization(cwd, lockRef, ownerOid, ownerToken) { + const recoveryRef = lockRef + WORKSPACE_RECOVERY_REF_SUFFIX; + const record = { + version: 1, + lockRef, + ownerOid, + ownerToken, + authorizedAt: Date.now(), + }; + const recordOid = await writeOwnerBlob(cwd, record); + const result = await runGit(cwd, ["update-ref", "--no-deref", recoveryRef, recordOid]); + if (result.exitCode !== 0) { + throw new Error("cannot persist workspace lock recovery authorization: " + ( + result.stderr.trim() || "git update-ref exited with code " + String(result.exitCode) + )); + } + return { ref: recoveryRef, oid: recordOid }; +} + +async function readRecoveryAuthorization(cwd, lockRef, options = {}) { + return await readCurrentOwner(cwd, lockRef + WORKSPACE_RECOVERY_REF_SUFFIX, options); +} + +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") { @@ -297,7 +335,6 @@ function makeOwner({ hostIdentity, ownerPid, ownerIdentity, now }) { } function createLease({ cwd, ref, oid, owner, heartbeatMs }) { - const localRefKey = ref; const ownerToken = owner.token; let currentOid = oid; let currentOwner = owner; @@ -349,10 +386,31 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { }); }; + 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; - void queueUpdate({}).catch(() => {}).finally(() => { heartbeatPending = false; }); + // 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?.(); @@ -380,9 +438,6 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { retain() { retained = true; }, - allowLocalRecovery() { - if (stopped && !retained && !released) locallyAbandonedRefs.set(localRefKey, currentOid); - }, async release() { if (released) return; if (releasePromise) return await releasePromise; @@ -404,6 +459,12 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { if (observed && observed.owner?.token === ownerToken) currentOid = observed.oid; } catch { /* best effort; the delete below still uses the last known OID */ } } + // Persist authorization before deleting. If deletion exhausts its + // retries, every bridge process sharing this lock store can safely CAS + // away only this exact completed owner record. + const recoveryAuthorization = await writeRecoveryAuthorization( + cwd, ref, currentOid, ownerToken, + ); let deleted = false; let deleteError = null; for (let attempt = 0; attempt < RELEASE_ATTEMPTS; attempt += 1) { @@ -421,6 +482,8 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { if (deleteError) throw deleteError; released = true; if (deleted) await writeRunHistory(cwd, ref, currentOwner); + await clearRecoveryAuthorization(cwd, recoveryAuthorization); + await maintainLockStore(cwd); if (lostError) throw lostError; if (!deleted) throw new Error("workspace lock ownership changed before release"); })(); @@ -450,12 +513,15 @@ export async function tryAcquireGitWorkspaceLock({ } = {}) { checkInterrupted(cancel, deadline); const ref = workspaceLockRef(key); - const localRefKey = ref; const current = await readCurrentOwner(cwd, ref, { cancel, deadline }); - const abandonedOid = locallyAbandonedRefs.get(localRefKey); - const locallyAbandoned = Boolean(current && abandonedOid === current.oid); - if (!current || (abandonedOid && !locallyAbandoned)) locallyAbandonedRefs.delete(localRefKey); - if (current && !locallyAbandoned && !await canReclaim(current.owner, { + const recovery = await readRecoveryAuthorization(cwd, ref, { cancel, deadline }); + const sharedRecoveryAuthorized = Boolean( + current && recovery?.owner?.version === 1 && + recovery.owner.lockRef === ref && + recovery.owner.ownerOid === current.oid && + recovery.owner.ownerToken === current.owner?.token, + ); + if (current && !sharedRecoveryAuthorized && !await canReclaim(current.owner, { now, staleMs, hostIdentity, processProbe, processIdentityProbe, operatorCleared, })) { return { acquired: false, reason: "held" }; @@ -472,7 +538,12 @@ export async function tryAcquireGitWorkspaceLock({ acquired = await compareAndSwap(cwd, ref, newOid, current.oid, { cancel, deadline }); } if (!acquired) return { acquired: false, reason: "contended" }; - locallyAbandonedRefs.delete(localRefKey); + if (recovery) { + await clearRecoveryAuthorization(cwd, { + ref: ref + WORKSPACE_RECOVERY_REF_SUFFIX, + oid: recovery.oid, + }); + } const lease = createLease({ cwd, ref, oid: newOid, owner, heartbeatMs }); try { checkInterrupted(cancel, deadline); @@ -480,10 +551,8 @@ export async function tryAcquireGitWorkspaceLock({ try { await lease.release(); } catch (releaseError) { - // Acquisition committed before cancellation/deadline was observed. If - // the compensating delete fails, preserve the exact OID for the next - // local FIFO holder instead of orphaning an unrecoverable live-owner ref. - lease.allowLocalRecovery(); + // 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; From bf51a027cb0f061f1017b600b1d72be4289caff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Mon, 17 Aug 2026 05:36:06 +0800 Subject: [PATCH 29/40] fix(cli-agent-bridge): harden delegated execution Add cross-process repository locking, fail-closed process containment, safe Git attribution, explicit quarantine recovery, Windows Job Object execution, and regression coverage for reviewed lifecycle and documentable safety boundaries. --- package.json | 2 +- plugins/Hylouis233/cli-agent-bridge/README.md | 59 +- .../cli-agent-bridge/git-executable.mjs | 120 ++ .../cli-agent-bridge/process-tree-runner.mjs | 279 ++++ .../cli-agent-bridge/process-tree.mjs | 536 ++++++-- .../cli-agent-bridge/ps1-json-runner.ps1 | 49 + .../cli-agent-bridge/ps1-runner.ps1 | 6 +- .../Hylouis233/cli-agent-bridge/server.mjs | 1039 +++++++++++---- .../skills/cli-agent-bridge/SKILL.md | 6 +- .../cli-agent-bridge/test/server.test.mjs | 7 +- .../cli-agent-bridge/tests/fake-backend.mjs | 19 + .../tests/process-tree.test.mjs | 670 +++++++++- .../cli-agent-bridge/tests/server.test.mjs | 1152 ++++++++++++++++- .../tests/workspace-lock.test.mjs | 68 +- .../cli-agent-bridge/windows-job-runner.ps1 | 160 +++ .../cli-agent-bridge/workspace-lock.mjs | 37 +- 16 files changed, 3738 insertions(+), 471 deletions(-) create mode 100644 plugins/Hylouis233/cli-agent-bridge/git-executable.mjs create mode 100644 plugins/Hylouis233/cli-agent-bridge/process-tree-runner.mjs create mode 100644 plugins/Hylouis233/cli-agent-bridge/ps1-json-runner.ps1 create mode 100644 plugins/Hylouis233/cli-agent-bridge/windows-job-runner.ps1 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/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index b1d3cd1..205c4b9 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -48,10 +48,11 @@ repository lock; use separate clones when the comparison must run in parallel. - 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. -- Supported operating systems: Windows, macOS, and Linux. The server is plain Node.js; on Windows, - shim-based CLIs additionally go through the bundled PowerShell 5.1 runner. End-to-end verified on - Windows (Claude Code 2.1.226, Kimi Code 0.30.0) and validated on Linux in a Node 22 container; - macOS uses the same POSIX path and is not yet machine-verified. +- Supported operating systems: Windows and Linux. The server is plain Node.js; on Windows, + delegated workers run inside a kill-on-close Job Object. End-to-end verified on + Windows (Claude Code 2.1.226, Kimi Code 0.30.0) and validated on Linux in a Node 22 container. + macOS/BSD fail closed before Git or backend launch because this dependency-free server cannot + prove containment of a child that clears its environment and escapes its original session. - Each backend CLI must be installed, on PATH, and signed in with your own account before use: | Backend | CLI | Status | Headless form used | @@ -78,17 +79,18 @@ your ZCode distribution provides one. The dsh template uses its documented headl the CLI_AGENT_BRIDGE_BACKENDS environment variable to a custom file) to adjust command, args, or binary paths. -On Windows, npm-style .ps1/.cmd shims cannot be launched directly, so the server retries them -through the bundled ps1-runner.ps1 using the built-in Windows PowerShell 5.1. Arguments pass -through verbatim with no cmd.exe re-interpretation. If a custom wrapper shim re-binds parameters -(for example a proxy autostart shim) and mangles dashed flags, set the backend command to the -underlying real executable in backends.json. +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 removes it. + 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 @@ -120,7 +122,11 @@ you already obtained a valid ID from that backend outside this Plugin. 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. A stale + repository prevents `git push --mirror` from publishing host/process/token metadata. + On Linux, the private store carries an atomically initialized persistent repository identity, + and 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 @@ -148,15 +154,22 @@ you already obtained a valid ID from that backend outside this Plugin. reused PID is never signaled, and a POSIX process group is only signaled while its original leader identity still matches. On Linux, descendants also inherit a per-run environment marker; if the parent exits before ancestry polling, the close path uses a bounded observation grace and - marker scans to recover children that become visible just after the leader exits, without - continuously scanning all of `/proc`. Repository discovery and snapshot Git commands use the same - containment, so a hook or helper cannot leave an untracked descendant behind after the lease is - released. If termination cannot be + marker scans recover children that become visible just after the leader exits, using stable + identities from `/proc`. Under extreme Linux process churn, if an identity-stable ancestry + sample cannot be completed, the bridge conservatively quarantines the workspace for the same + operator-verified manual recovery described below. 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, moves its lease into the recoverable `quarantined` state, and every bridge process refuses further delegation until an operator checks for leftovers and deliberately - removes the reported quarantinePath - removing that marker also authorizes the next delegation - to reclaim the quarantined lease. If the bridge crashes mid-run, a lease recording a running + 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 @@ -179,9 +192,11 @@ you already obtained a valid ID from that backend outside this Plugin. 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; - remote-tracking updates and fetched tag-only tips are treated as externally sourced history and - excluded from worker attribution, including when a local worker commit builds on the fetched - tip. Any + remote-tracking updates and fetched tag-only tips are treated as externally sourced history. + FETCH_HEAD tips supply the same external baseline when fetch writes through an arbitrary refspec + directly into a local branch or custom ref, including when a local worker commit builds on the + fetched tip. Commit-tip classification is batched 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, @@ -200,8 +215,8 @@ 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 the operator -removes the marker, interruptible lease state updates, shared quarantine markers, queued and +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 process-tree termination, escaped POSIX descendants and zombie-only Linux groups, PID-reuse identity checks before signaling, unusual Git pathnames (including a trailing-space worktree 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..6a877d3 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs @@ -0,0 +1,120 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { access, mkdir, realpath, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +let executablePromise = null; +let hooksRootPromise = null; +const pathCommandPromises = new Map(); + +function userScope() { + let identity; + try { + const user = os.userInfo(); + identity = Number.isInteger(user.uid) && user.uid >= 0 + ? process.platform + ":uid:" + String(user.uid) + : process.platform + ":" + user.username + ":" + user.homedir; + } catch { + identity = process.platform + ":" + (process.env.USERNAME ?? process.env.USER ?? os.homedir()); + } + return createHash("sha256").update(identity).digest("hex").slice(0, 20); +} + +const DISABLED_HOOKS_ROOT = path.join( + os.tmpdir(), "minimax-cli-agent-bridge-git-" + userScope(), "disabled-hooks", +); + +async function resolveGitExecutable() { + 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 (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; +} + +export function resolvePathCommand(command) { + if (typeof command !== "string" || !command) return Promise.resolve(null); + if (!pathCommandPromises.has(command)) { + pathCommandPromises.set(command, resolvePathCommandUncached(command)); + } + return pathCommandPromises.get(command); +} + +export function trustedGitExecutable() { + executablePromise ??= resolveGitExecutable(); + return executablePromise; +} + +async function disabledHooksRoot() { + hooksRootPromise ??= mkdir(DISABLED_HOOKS_ROOT, { recursive: true, mode: 0o700 }) + .then(() => DISABLED_HOOKS_ROOT) + .catch((error) => { + hooksRootPromise = null; + throw error; + }); + return await hooksRootPromise; +} + +export async function safeGitInvocation(args) { + const safeArgs = [ + "-c", "core.hooksPath=" + await disabledHooksRoot(), + "-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"); + const env = { + ...process.env, + GIT_OPTIONAL_LOCKS: "0", + GIT_PAGER: "", + PAGER: "", + }; + for (const name of [ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG_COUNT", "GIT_DIR", "GIT_DIFF_OPTS", + "GIT_EXTERNAL_DIFF", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_WORK_TREE", + ]) delete env[name]; + for (const name of Object.keys(env)) { + if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/u.test(name)) delete env[name]; + } + return { command: await trustedGitExecutable(), args: safeArgs, env }; +} 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..badaa72 --- /dev/null +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree-runner.mjs @@ -0,0 +1,279 @@ +// 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 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; + return isFile(entry) ? entry : null; +} + +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: process.env, 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(entry, payload) { + let worker; + try { + worker = spawn(process.execPath, [entry, ...payload.args], { + cwd: process.cwd(), env: process.env, 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: process.env, 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: process.env, + 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"))) { + 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 index e5be520..ada4191 100644 --- a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs @@ -9,20 +9,23 @@ function appendBounded(current, chunk) { return combined.length > UTILITY_CAPTURE_CHARS ? combined.slice(-UTILITY_CAPTURE_CHARS) : combined; } -function runUtility(command, args, timeoutMs = 5_000) { +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 }); + resolve({ exitCode, stdout, stderr, error, stdoutTruncated, stderrTruncated }); }; const timer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* already gone */ } @@ -30,78 +33,96 @@ function runUtility(command, args, timeoutMs = 5_000) { }, timeoutMs); 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.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 = "$p=Get-CimInstance Win32_Process -Filter 'ProcessId = " + String(pid) + - "'; if ($null -ne $p) { $p.CreationDate.ToUniversalTime().Ticks.ToString() }"; + 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, ]); - if (result.exitCode !== 0) { + 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 result.stdout.trim() || null; + 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])); - const matchesIdentity = (item) => { - if (!item.startIdentity) { - throw new Error("cannot verify Windows process creation identity for PID " + String(item.pid)); - } - const expected = treeState.knownStarts.get(item.pid); - return !expected || expected === item.startIdentity; - }; for (const pid of [...treeState.knownPids]) { const item = byPid.get(pid); const expected = treeState.knownStarts.get(pid); - if (item && expected && item.startIdentity && expected !== item.startIdentity) { - treeState.knownPids.delete(pid); - } + if (item && expected && expected !== item.startIdentity) treeState.knownPids.delete(pid); } const descendants = new Set(); const parents = new Set(); - const root = byPid.get(rootPid); - if (root && matchesIdentity(root)) { - const expectedRoot = treeState.knownStarts.get(rootPid); - if (expectedRoot || treeState.windowsSnapshotInitialized !== true) { - descendants.add(rootPid); - parents.add(rootPid); - treeState.knownStarts.set(rootPid, root.startIdentity); - } - } - // During the first relevant snapshot the root may have just exited while - // Win32_Process still records its children with the original parent PID. - if (treeState.windowsSnapshotInitialized !== true) parents.add(rootPid); - for (const pid of treeState.knownPids) { + 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); - if (pid === rootPid && treeState.windowsSnapshotInitialized === true && - !treeState.knownStarts.has(pid)) continue; - if (!item || !matchesIdentity(item)) continue; + 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)) continue; - if (!matchesIdentity(item)) continue; + 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; } } - treeState.windowsSnapshotInitialized = true; for (const pid of descendants) treeState.knownPids.add(pid); return [...descendants]; } @@ -109,20 +130,16 @@ export function trackedWindowsProcessTreePids(rootPid, treeState, processes) { export async function windowsProcessTreePids( rootPid, treeState = { knownPids: new Set(), knownStarts: new Map() }, - { runUtility: run = runUtility } = {}, + { runUtility: run = runUtility, windowsSnapshot } = {}, ) { - const seeds = [...new Set([rootPid, ...treeState.knownPids])] - .filter((pid) => Number.isInteger(pid) && pid > 0) - .join(","); + if (windowsSnapshot) { + return trackedWindowsProcessTreePids(rootPid, treeState, await windowsSnapshot({ + rootPid, knownPids: [...treeState.knownPids], + })); + } const script = [ "$ErrorActionPreference='Stop'", - `$seed=@(${seeds})`, - "$queue=New-Object 'System.Collections.Generic.Queue[uint32]'", - "$seed | ForEach-Object { $queue.Enqueue([uint32]$_) }", - "$expanded=@{}", - "$itemSeen=@{}", - "$items=@()", - "while($queue.Count -gt 0){$parent=$queue.Dequeue();if($expanded.ContainsKey($parent)){continue};$expanded[$parent]=$true;$filter=\"ProcessId = $parent OR ParentProcessId = $parent\";foreach($p in @(Get-CimInstance Win32_Process -Filter $filter)){if(-not $itemSeen.ContainsKey($p.ProcessId)){$itemSeen[$p.ProcessId]=$true;$identity=$(if($null -eq $p.CreationDate){''}else{$p.CreationDate.ToUniversalTime().Ticks.ToString()});$items += [pscustomobject]@{ProcessId=[uint32]$p.ProcessId;ParentProcessId=[uint32]$p.ParentProcessId;CreationTicks=$identity}};if(-not $expanded.ContainsKey($p.ProcessId)){$queue.Enqueue([uint32]$p.ProcessId)}}}", + "$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", [ @@ -142,16 +159,37 @@ export async function windowsProcessTreePids( return trackedWindowsProcessTreePids(rootPid, treeState, processes); } -export async function initializeProcessTree(child, treeState) { +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 (process.platform === "win32") { - // taskkill /T starts from the live ChildProcess root. Defer CIM until - // close/termination so short-lived workers do not launch an expensive WMI - // query solely to prove that an already-closed root is gone. + 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; } - await refreshProcessTree(child, treeState); + 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) { @@ -172,11 +210,17 @@ function parseLinuxStat(pid, statLine) { ? 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 (error.code === "ENOENT") return undefined; + if (isLinuxProcessGone(error)) return undefined; throw error; } } @@ -222,7 +266,7 @@ export async function linuxProcessGroupHasLiveMembers( return members.some((item) => isLiveState(item.state)); } -async function linuxMarkedProcessPids(marker, procRoot, fsOps) { +async function linuxMarkedProcesses(marker, procRoot, fsOps) { let entries; try { entries = await fsOps.readdir(procRoot, { withFileTypes: true }); @@ -231,18 +275,30 @@ async function linuxMarkedProcessPids(marker, procRoot, fsOps) { } 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)) matches.push(Number(entry.name)); + 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 (!["ENOENT", "EACCES", "EPERM"].includes(error.code)) return null; + if (!isLinuxProcessGone(error) && !["EACCES", "EPERM"].includes(error.code)) return null; } } return matches; @@ -251,13 +307,93 @@ async function linuxMarkedProcessPids(marker, procRoot, fsOps) { // 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) { +async function linuxTrackedProcessSnapshot( + rootPid, + treeState, + procRoot, + fsOps, + allowRootIdentityCapture = false, +) { treeState.knownStarts ??= new Map(); - const queue = [...new Set([rootPid, ...treeState.knownPids])]; - const queued = new Set(queue); + 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 = queue[index]; + const { pid, parentPid, parentStartIdentity } = queue[index]; let item; try { item = await readLinuxStat(pid, procRoot, fsOps); @@ -265,66 +401,144 @@ async function linuxTrackedProcessSnapshot(rootPid, treeState, procRoot, fsOps) return null; } if (item === undefined) { - if (pid === rootPid && treeState.runMarker) { - const markedPids = await linuxMarkedProcessPids(treeState.runMarker, procRoot, fsOps); - if (markedPids === null) return null; - for (const markedPid of markedPids) { - if (queued.has(markedPid)) continue; - queued.add(markedPid); - queue.push(markedPid); - } + if (pid === rootPid || parentPid !== null) { + await enqueueMarkedProcesses({ stable: true }); } continue; } if (item === null) return null; const expected = treeState.knownStarts.get(pid); - if (expected && item.startIdentity && expected !== item.startIdentity) continue; + 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); - let taskEntries; - try { - taskEntries = await fsOps.readdir(`${procRoot}/${pid}/task`, { withFileTypes: true }); - } catch (error) { - if (error.code === "ENOENT") continue; - return null; - } - for (const taskEntry of taskEntries) { - if (!taskEntry.isDirectory() || !/^\d+$/u.test(taskEntry.name)) continue; - let children; + const pendingChildren = new Set(); + let parentAfter = item; + let taskDirectoryGone = false; + let stableTaskSample = false; + for (let taskAttempt = 0; taskAttempt < 3; taskAttempt += 1) { + let taskEntries; try { - children = await fsOps.readFile( - `${procRoot}/${pid}/task/${taskEntry.name}/children`, "utf8", - ); + taskEntries = await fsOps.readdir(`${procRoot}/${pid}/task`, { withFileTypes: true }); } catch (error) { - if (error.code === "ENOENT") continue; + 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 (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; } - for (const value of children.trim().split(/\s+/u)) { - if (!value) continue; - const childPid = Number(value); - if (!Number.isInteger(childPid) || childPid <= 0 || queued.has(childPid)) continue; - queued.add(childPid); - queue.push(childPid); + 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); } } - processes.incomplete = false; + // This targeted ancestry/marker walk is not a complete process-group scan. + processes.incomplete = true; return processes; } -async function posixProcessSnapshot({ +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 runUtility("ps", ["-axo", "pid=,ppid=,pgid=,stat=,lstart="]); - if (result.exitCode !== 0) return null; - const processes = result.stdout.split(/\r?\n/u) - .map(parsePosixProcessLine) - .filter(Boolean); + 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; } @@ -365,6 +579,7 @@ export async function refreshProcessTree(child, treeState, options = {}) { treeState, options.procRoot ?? "/proc", options.fsOps ?? { readdir, readFile }, + options.allowRootIdentityCapture === true, ) : await posixProcessSnapshot(options); if (processes === null) return null; @@ -374,14 +589,15 @@ export async function refreshProcessTree(child, treeState, options = {}) { // 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); - const leaderIsOriginal = Boolean(leader) && - (!expectedLeaderStart || !leader.startIdentity || leader.startIdentity === expectedLeaderStart); - if (leaderIsOriginal && leader.startIdentity && !expectedLeaderStart) { + 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 !expected || !item.startIdentity || expected === item.startIdentity; + 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. @@ -412,27 +628,42 @@ export async function isProcessTreeAlive(child, treeState, { 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)).length > 0; + return (await windowsProcessTreePids(child.pid, treeState, { + runUtility: run, windowsSnapshot, + })).length > 0; } - let processes = await refreshProcessTree(child, treeState, { platform, procRoot, fsOps }); + 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) { - // A reused PID leading an unrelated group must not count as our tree. - return !leaderStart || !item.startIdentity || item.startIdentity === leaderStart; - } + 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 @@ -448,13 +679,18 @@ export async function isProcessTreeAlive(child, treeState, { const observationDeadline = Date.now() + observationGraceMs; while (Date.now() < observationDeadline) { await new Promise((resolve) => setTimeout(resolve, 25)); - processes = await refreshProcessTree(child, treeState, { platform, procRoot, fsOps }); + 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 false; + if (processes !== null && ignoreZombieOnly && !processes.incomplete) { + return treeState.processIdentityUncertain === true ? true : false; + } } try { probeProcessGroup(child.pid); @@ -462,6 +698,7 @@ export async function isProcessTreeAlive(child, treeState, { // 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") { @@ -475,27 +712,63 @@ 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") { - // The ChildProcess handle identifies the current root, so terminate its tree - // immediately. Retained PIDs are then checked by creation identity. - if (child.exitCode === null && child.signalCode === null) { - await run("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"]); - } await treeState.initialRefresh; - const remaining = await windowsProcessTreePids(child.pid, treeState, { runUtility: run }); - for (const pid of remaining.reverse()) { - const expected = treeState.knownStarts.get(pid); - const current = await windowsProcessStartIdentity(pid, run); - if (!current || !expected || current !== expected) continue; - await run("taskkill.exe", ["/PID", String(pid), "/T", "/F"]); + 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 }); + 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 @@ -504,12 +777,18 @@ export async function signalProcessTree(child, signal, treeState, { // 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 leader = byPid.get(child.pid); const leaderStart = treeState.knownStarts?.get(child.pid); - const groupIsOriginal = Boolean(leader) && - leader.processGroupId === child.pid && - isLiveState(leader.state) && - (!leaderStart || !leader.startIdentity || leader.startIdentity === leaderStart); + 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); @@ -524,7 +803,8 @@ export async function signalProcessTree(child, signal, treeState, { // 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 (item && expected && item.startIdentity && expected !== item.startIdentity) continue; + if (processes !== null && (!item || !expected || !item.startIdentity || + expected !== item.startIdentity)) continue; try { killOne(pid, signal); } catch (error) { if (error.code !== "ESRCH") throw error; } } 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 index 71f5462..4e892da 100644 --- a/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 +++ b/plugins/Hylouis233/cli-agent-bridge/ps1-runner.ps1 @@ -1,8 +1,12 @@ # 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] -$rest = $args | Select-Object -Skip 1 +[string[]]$rest = @($args | Select-Object -Skip 1) if ([string]::IsNullOrWhiteSpace($Command)) { [Console]::Error.WriteLine("backend command is missing") exit 127 diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 18b711d..e48c754 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -8,11 +8,12 @@ import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { link, mkdir, readFile, realpath, stat, unlink, writeFile } from "node:fs/promises"; +import { chmod, link, mkdir, mkdtemp, open, readFile, realpath, rename, rm, 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 } from "./git-executable.mjs"; import { initializeProcessTree, isProcessTreeAlive, refreshProcessTree, signalProcessTree, waitForChildExit, waitForProcessTreeExit } from "./process-tree.mjs"; import { acquireGitWorkspaceLock, @@ -37,6 +38,27 @@ const KILL_GRACE_MS = Number.isInteger(TEST_KILL_GRACE_MS) && TEST_KILL_GRACE_MS : 10_000; const MAX_CAPTURE_CHARS = 5_000_000; const RAW_TAIL_CHARS = 60_000; +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; + +function supportsReliableProcessContainment(platform = RUNTIME_PLATFORM) { + return platform === "linux" || platform === "win32"; +} + +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", + ); +} + function currentUserLockScope() { let identity; try { @@ -232,7 +254,7 @@ function capture(binary = false) { }; } -async function runCommand(command, args, options = {}) { +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()) { @@ -245,13 +267,48 @@ async function runCommand(command, args, options = {}) { 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 processTreeRunnerPayload = (useWindowsJobRunner || useProcessTreeRunner) ? JSON.stringify({ + command, + args: argv, + ...(options.stdinText === undefined ? {} : { stdinText: options.stdinText }), + }) : ""; const linuxRunMarker = manageProcessTree && process.platform === "linux" ? randomUUID() : null; + const baseEnvironment = options.env ?? process.env; const childEnvironment = linuxRunMarker - ? { ...process.env, CLI_AGENT_BRIDGE_RUN_ID: linuxRunMarker } - : process.env; - const child = shellArgs + ? { ...baseEnvironment, CLI_AGENT_BRIDGE_RUN_ID: linuxRunMarker } + : baseEnvironment; + 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, @@ -266,9 +323,18 @@ async function runCommand(command, args, options = {}) { 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; @@ -276,7 +342,25 @@ async function runCommand(command, args, options = {}) { 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(), @@ -285,88 +369,164 @@ async function runCommand(command, args, options = {}) { }; let treeRefreshPromise = null; let treeRefreshTimer = null; + let treeRefreshStopped = false; const refreshTree = () => { - if (!manageProcessTree) return Promise.resolve(); + if (!trackProcessTree || treeRefreshStopped) return Promise.resolve(); if (treeRefreshPromise) return treeRefreshPromise; - treeRefreshPromise = refreshProcessTree(child, treeState) - .catch((error) => { terminationError ||= "process-tree inspection failed: " + error.message; }) + 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 timeoutMs = options.timeoutMs ?? 30_000; - const killGraceMs = options.killGraceMs ?? KILL_GRACE_MS; + 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; - settled = 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(), - }); + 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 (reason === "timeout") timedOut = true; - if (reason === "orphaned") orphanedProcesses = true; + if (settled) return Promise.resolve(); if (terminationPromise) return terminationPromise; - terminationPromise = (async () => { - if (manageProcessTree) await signalProcessTree(child, "SIGTERM", treeState); + 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 = manageProcessTree - ? await waitForProcessTreeExit(child, killGraceMs, treeState) + const exited = trackProcessTree + ? await waitForTreeExit(child, killGraceMs, treeState) : await waitForChildExit(child, killGraceMs); if (!exited) { killed = true; - if (manageProcessTree) await signalProcessTree(child, "SIGKILL", treeState); + if (trackProcessTree) await signalTree(child, "SIGKILL", treeState); else try { child.kill("SIGKILL"); } catch { /* already gone */ } - treeTerminated = manageProcessTree - ? await waitForProcessTreeExit(child, killGraceMs, treeState, { ignoreZombieOnly: true }) + treeTerminated = trackProcessTree + ? await waitForTreeExit(child, killGraceMs, treeState, { ignoreZombieOnly: true }) : await waitForChildExit(child, killGraceMs); if (!treeTerminated) { - terminationError = "process tree still appears alive after forceful termination"; + terminationError ||= "process tree still appears alive after forceful termination"; } } - settle(); - })().catch((error) => { + if (treeState.processInspectionUncertain === true) treeTerminated = false; + }).catch((error) => { treeTerminated = false; - terminationError = error.message; - settle(); + terminationError ||= error.message; }); + terminationPromise = terminationCleanupPromise.then(() => settle()); return terminationPromise; }; - if (typeof options.onChild === "function" && Number.isInteger(child.pid)) { - options.onChild({ child, terminate }); - } - if (manageProcessTree) { + if (trackProcessTree) { // Capture the root's start identity immediately so termination can later - // detect a reused PID. Windows performs this one-shot inspection only; - // POSIX keeps polling to track descendants that escape the process group. + // 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. - treeState.initialRefresh = initializeProcessTree(child, treeState).catch((error) => { + 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") { + if (process.platform !== "win32" || options.refreshProcessTree) { const refreshIntervalMs = process.platform === "linux" ? 25 : 250; treeRefreshTimer = setInterval(() => { void refreshTree(); }, refreshIntervalMs); treeRefreshTimer.unref?.(); } } - if (child.stdin) child.stdin.end(options.stdinText); + // 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); + } + } - const timer = setTimeout(() => { void terminate("timeout"); }, timeoutMs); + timer = setTimeout(() => { void terminate("timeout"); }, timeoutMs); if (!binaryStdout) child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); @@ -374,9 +534,16 @@ async function runCommand(command, args, options = {}) { child.stderr.on("data", (chunk) => { stderrBuf.push(chunk); }); child.on("error", (error) => { - spawnError = 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({ @@ -394,34 +561,53 @@ async function runCommand(command, args, options = {}) { stderrTruncated: stderrBuf.truncated(), }); }); - child.on("close", async (code) => { + child.on("exit", (code) => { if (settled) return; exitCode = code; if (terminationPromise) return; - if (manageProcessTree) { - let stillAlive = false; + void (async () => { try { - stillAlive = await isProcessTreeAlive(child, treeState); + 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; - settle(); - return; + await settle(); } - if (stillAlive) { - await terminate("orphaned"); - return; - } - } - 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, @@ -429,7 +615,7 @@ async function runCommand(command, args, options = {}) { // verbatim (no cmd.exe re-interpretation). const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "ps1-runner.ps1"); return await spawnOnce(null, [ - "powershell.exe", + trustedWindowsPowerShell(), "-NoProfile", "-NonInteractive", "-ExecutionPolicy", @@ -553,7 +739,10 @@ async function gitCommonDirectory(workspacePath, options = {}) { } } -function repositoryLockKey(gitCommonDir) { +function repositoryLockKey(gitCommonDir, repositoryId = null) { + if (process.platform === "linux" && 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 @@ -561,7 +750,112 @@ function repositoryLockKey(gitCommonDir) { 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_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + +async function readRepositoryId(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"); + } + const value = (await interruptibleFilesystemOperation(readFile(idPath, "utf8"), options)).trim(); + if (!REPOSITORY_ID_PATTERN.test(value)) { + throw new Error("workspace lock store repository identity is malformed"); + } + return value; +} + +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 ensureRepositoryId(storeRoot, options = {}) { + const idPath = path.join(storeRoot, REPOSITORY_ID_FILE); + try { + return await readRepositoryId(idPath, options); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + const candidateId = randomUUID(); + const candidatePath = idPath + ".candidate-" + String(process.pid) + "-" + randomUUID(); + const mode = await repositoryIdMode(storeRoot, options); + const writing = writeFile(candidatePath, candidateId + "\n", { flag: "wx", mode }); + try { + await interruptibleFilesystemOperation(writing, options); + } catch (error) { + // A cancellation cannot abort the underlying write. Remove a candidate + // that arrives after the request has already stopped waiting for it. + void writing.then(() => unlink(candidatePath)).catch(() => {}); + throw error; + } + try { + await interruptibleFilesystemOperation(chmod(candidatePath, mode), options); + try { + await interruptibleFilesystemOperation(link(candidatePath, idPath), options); + } catch (error) { + if (error.code !== "EEXIST") throw error; + } + } finally { + await unlink(candidatePath).catch((error) => { + if (error.code !== "ENOENT") throw error; + }); + } + return await readRepositoryId(idPath, options); +} + async function ensureWorkspaceLockStore(gitCommonDir, options = {}) { const storeRoot = path.join(gitCommonDir, WORKSPACE_LOCK_STORE_NAME); let initialized = false; @@ -582,52 +876,85 @@ async function ensureWorkspaceLockStore(gitCommonDir, options = {}) { throw new Error("cannot inspect repository sharing mode: " + sharedFailure); } const shared = sharedResult.stdout.replace(/\r?\n$/u, ""); - const initArgs = ["init", "--bare", "--quiet"]; - if (shared) initArgs.push("--shared=" + shared); - initArgs.push(storeRoot); - const result = await runGitCommand(initArgs, { - cwd: gitCommonDir, ...options, - }); - const failure = snapshotFailure("git init --bare workspace lock store", result); - if (failure) throw new Error("cannot initialize workspace lock store: " + failure); + 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 }); + } } - return await interruptibleFilesystemOperation(realpath(storeRoot), options); + 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. + return { root: storeRoot, repositoryId: await ensureRepositoryId(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 GitProcessTreeUnconfirmedError extends Error { + constructor(label, terminationError, quarantine = null) { + super(label + " process tree could not be confirmed terminated: " + terminationError); + this.terminationError = terminationError; + this.quarantine = quarantine; + } +} -async function runGitCommand(args, { +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 remaining = deadline === null ? GIT_TIMEOUT_MS : deadline - Date.now(); if (remaining <= 0) throw new DeadlineExceededError("delegation deadline exceeded"); + const git = await safeGitInvocation(args); let controller = null; - const result = await runCommand("git", args, { + const result = await commandRunner(git.command, git.args, { cwd, + env: git.env, stdinText, binaryStdout, - manageProcessTree: true, - // Git hooks/helpers are polled while Git is alive and receive one final - // marker scan after it exits. Do not add the worker-oriented 500 ms late- - // visibility grace to every merge-base/ref query: attribution can issue - // hundreds of these commands. A marked descendant found by the final scan - // is still terminated and drained before this call returns. + manageProcessTree: containProcessTree, markerObservationGraceMs: 0, timeoutMs: Math.max(1, Math.min(timeoutMs, remaining)), killGraceMs: 1_000, @@ -638,9 +965,15 @@ async function runGitCommand(args, { }, }); if (cancel?.controller === controller) cancel.controller = null; - if (cancel?.cancelled) throw new OperationCancelledError("operation cancelled by client"); - if (deadline !== null && result.timedOut && Date.now() >= deadline) { - throw new DeadlineExceededError("delegation deadline exceeded"); + // 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; } @@ -652,39 +985,48 @@ const CONCURRENT_LEASE_STALE_MS = 30_000; async function gitSnapshot(worktreeRoot, options = {}) { const ownLockRef = typeof options.ownLockRef === "string" ? options.ownLockRef : null; + const lockStoreRoot = typeof options.lockStoreRoot === "string" ? options.lockStoreRoot : null; const jobs = [ - ["git status --short", "status", ["status", "--short", "--untracked-files=all", "--ignore-submodules=none"]], - ["git diff --stat", "diffStat", ["diff", "--ignore-submodules=none", "--stat"]], - ["git diff --name-only -z", "diffNames", ["diff", "--ignore-submodules=none", "--name-only", "-z"], false, true], + ["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 results = []; + const out = {}; for (const job of jobs) { - results.push(await runGitCommand(job[2], { + const result = await runGitCommand(job[2], { cwd: worktreeRoot, ...options, binaryStdout: job[4] === true, - })); - } - const failures = []; - const out = {}; - results.forEach((result, i) => { - if (jobs[i][3] === true && result.exitCode === 1 && !result.timedOut) { - out[jobs[i][1]] = ""; - return; + // 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); } - const failure = snapshotFailure(jobs[i][0], result); - if (failure) { failures.push(failure); return; } - out[jobs[i][1]] = result.stdout; - }); - if (failures.length > 0) { - // Fail closed: an unreliable snapshot must never authorize a delegation. - throw new Error("git snapshot unreliable: " + failures.join("; ")); + 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) => { @@ -721,17 +1063,47 @@ async function gitSnapshot(worktreeRoot, options = {}) { .map((s, i) => (i === 0 ? s : s.split(/\r?\n/).map((l) => "staged: " + l).join("\n"))) .join("\n"); const refs = {}; - const lockRefs = []; 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); - if (ref.startsWith(WORKSPACE_LOCK_REF_PREFIX)) { + refs[ref] = line.slice(separator + 1); + } + const fetchHeads = []; + const fetchHeadPath = path.resolve( + worktreeRoot, String(out.fetchHeadPath ?? "").replace(/\r?\n$/u, ""), + ); + try { + const rawFetchHead = await interruptibleFilesystemOperation(readFile(fetchHeadPath, "utf8"), 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); + } + } + const lockRefs = []; + if (lockStoreRoot) { + const storedRefs = await runGitCommand([ + "for-each-ref", "--format=%(refname)%09%(objectname)", WORKSPACE_LOCK_REF_PREFIX, + ], { cwd: lockStoreRoot, ...options }); + const storedRefsFailure = snapshotFailure("git for-each-ref workspace lock store", storedRefs); + if (storedRefsFailure) throw new Error("git snapshot unreliable: " + storedRefsFailure); + for (const line of String(storedRefs.stdout ?? "").split(/\r?\n/u)) { + if (!line) continue; + const separator = line.indexOf("\t"); + if (separator <= 0) continue; + const ref = line.slice(0, separator); if (ref !== ownLockRef) lockRefs.push(line.slice(separator + 1)); - continue; } - refs[ref] = line.slice(separator + 1); } // Linked worktrees serialize per worktree but share repository refs, so a // commit from a parallel delegation can land between our two snapshots. @@ -743,7 +1115,7 @@ async function gitSnapshot(worktreeRoot, options = {}) { : Number.POSITIVE_INFINITY; let concurrentDelegations = 0; for (const oid of lockRefs) { - const blob = await runGitCommand(["cat-file", "blob", oid], { cwd: worktreeRoot, ...options }); + const blob = await runGitCommand(["cat-file", "blob", oid], { cwd: lockStoreRoot, ...options }); if (blob.exitCode !== 0) continue; // unreadable owner blob: ignore for disclosure try { const record = JSON.parse(blob.stdout); @@ -770,6 +1142,7 @@ async function gitSnapshot(worktreeRoot, options = {}) { head: String(out.head ?? "").trim(), headRef: String(out.headRef ?? "").replace(/\r?\n$/u, ""), refs, + fetchHeads, concurrentDelegations, }; } @@ -799,59 +1172,60 @@ async function peelCommitish(worktreeRoot, oid, cache = new Map(), options = {}) return commit; } -async function closestExistingBase(worktreeRoot, target, baselineCommits, options = {}) { - async function distanceFromTarget(candidate) { - const distance = await runGitCommand(["rev-list", "--count", candidate + ".." + target], { - cwd: worktreeRoot, ...options, - }); - const failure = snapshotFailure("git rev-list --count " + candidate + ".." + target, distance); - const count = Number(distance.stdout.trim()); - if (failure || !Number.isInteger(count)) { - throw new Error("cannot select committed-delta baseline: " + (failure || "invalid distance")); - } - return count; - } - - let best = null; - let bestDistance = Number.POSITIVE_INFINITY; - // Check every pre-run tip in bounded, cancellable commands. This avoids a - // command-line-size limit and never silently drops late refs from attribution. - for (const candidate of baselineCommits) { - const ancestor = await runGitCommand(["merge-base", "--is-ancestor", candidate, target], { - cwd: worktreeRoot, ...options, +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", }); - if (ancestor.timedOut || ancestor.stdoutTruncated || ancestor.stderrTruncated || - ![0, 1].includes(ancestor.exitCode)) { - throw new Error("cannot select committed-delta baseline: git merge-base --is-ancestor failed"); + 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"); } - if (ancestor.exitCode !== 0) continue; - const distance = await distanceFromTarget(candidate); - if (distance < bestDistance) { - best = candidate; - bestDistance = distance; + 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); } } - if (best) return best; +} - // Rewritten histories may have no pre-run tip that remains a direct ancestor. - // Evaluate each merge base independently and retain the closest one. - for (const candidate of baselineCommits) { - const mergeBase = await runGitCommand(["merge-base", target, candidate], { - cwd: worktreeRoot, ...options, - }); - if (mergeBase.exitCode === 1 && !mergeBase.timedOut) continue; - const failure = snapshotFailure("git merge-base " + target + " " + candidate, mergeBase); - const merged = mergeBase.stdout.trim(); - if (failure || !merged) { - throw new Error("cannot select committed-delta baseline: " + (failure || "empty merge base")); - } - const distance = await distanceFromTarget(merged); - if (distance < bestDistance) { - best = merged; - bestDistance = distance; - } +export async function closestExistingBase(worktreeRoot, target, baselineCommits, options = {}) { + if (baselineCommits.length === 0) return null; + // 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", target, "--stdin", + ], { + cwd: worktreeRoot, + ...options, + 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); + for (const line of String(walk.stdout ?? "").split(/\r?\n/u)) { + if (!line.startsWith("-")) continue; + const boundary = line.slice(1).trim(); + if (/^[0-9a-f]{40,64}$/u.test(boundary)) return boundary; } - return best; + // Disjoint histories have no excluded boundary; callers use the empty tree + // so the stat still represents the newly reachable target history. + return null; } async function committedDelta(worktreeRoot, before, after, options = {}) { @@ -870,6 +1244,7 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { cwd: worktreeRoot, ...options, stdinText: "", + containProcessTree: true, }); const failure = snapshotFailure("git mktree", emptyTree); if (failure || !emptyTree.stdout.trim()) { @@ -880,6 +1255,15 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { } const cache = new Map(); + await populateCommitishCache(worktreeRoot, [ + before.head, + ...Object.values(before.refs ?? {}), + ...(before.fetchHeads ?? []), + after.head, + ...Object.values(after.refs ?? {}), + ...(after.fetchHeads ?? []), + ...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 @@ -892,6 +1276,7 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { await addBaseline(before.head); for (const oid of new Set([ ...Object.values(before.refs ?? {}), + ...(before.fetchHeads ?? []), ...refsChanged.map((change) => change.before), ])) { await addBaseline(oid); @@ -920,6 +1305,12 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { if (tagCommit && !movedLocalTargets.has(tagCommit)) externalRefChanges.push(change); } for (const change of externalRefChanges) await addBaseline(change.after); + // FETCH_HEAD records the actual objects downloaded by fetch independently + // of its arbitrary destination refspec. A fetch can write directly into + // refs/heads or any custom namespace, so namespace alone cannot establish + // provenance. Exclude every recorded fetched tip while retaining the local + // destination movement as an attribution target for any later worker commit. + for (const oid of after.fetchHeads ?? []) await addBaseline(oid); const externalRefNames = new Set(externalRefChanges.map((change) => change.ref)); // A worker committing on the checked-out branch moves HEAD and its branch ref @@ -946,6 +1337,15 @@ async function committedDelta(worktreeRoot, before, after, options = {}) { const movementLogs = externalRefChanges.map((change) => change.ref + " moved to externally sourced history; excluded from worker-created commits", ); + const beforeFetchHeads = new Set(before.fetchHeads ?? []); + for (const oid of after.fetchHeads ?? []) { + if (!beforeFetchHeads.has(oid)) { + movementLogs.push( + "FETCH_HEAD recorded externally fetched history at " + oid.slice(0, 12) + + "; excluded from worker-created commits", + ); + } + } if ((before.headRef ?? "") !== (after.headRef ?? "")) { movementLogs.push( "HEAD symbolic target " + (before.headRef || "(detached)") + @@ -1066,8 +1466,38 @@ async function listBackends(cancel = null) { // the discovery call, terminating the current probe and skipping the rest. if (cancel?.cancelled) break; if (!spec || typeof spec.command !== "string") continue; - const check = await runCommand(spec.command, ["--version"], { + if (!supportsReliableProcessContainment()) { + entries.push({ + name, + label: typeof spec.label === "string" ? spec.label : name, + command: spec.command, + available: false, + experimental: Boolean(spec.experimental), + version: null, + error: "unsupported platform: reliable descendant containment is available only on Windows and Linux", + resumeSupported: Array.isArray(spec.resumeArgs), + notes: typeof spec.notes === "string" ? spec.notes : "", + }); + continue; + } + const resolvedCommand = await resolvePathCommand(spec.command); + 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; @@ -1094,6 +1524,10 @@ function workspaceQuarantinePath(key) { return path.join(WORKSPACE_LOCK_ROOT, digest + ".quarantine"); } +function workspaceQuarantineRecoveryPath(key) { + return workspaceQuarantinePath(key) + ".recovery-approved"; +} + async function readWorkspaceQuarantine(key) { const quarantinePath = workspaceQuarantinePath(key); try { @@ -1107,34 +1541,54 @@ async function readWorkspaceQuarantine(key) { } } -// Quarantined leases become reclaimable through this check: the operator -// deliberately removed the shared marker after inspecting leftover processes. -async function quarantineFileAbsent(key) { +// 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(key, owner) { try { - return (await readWorkspaceQuarantine(key)) === null; + const raw = await readFile(workspaceQuarantineRecoveryPath(key), "utf8"); + const record = JSON.parse(raw); + return typeof owner?.quarantineId === "string" && + record?.quarantineId === owner.quarantineId; } catch { return false; } } +async function clearQuarantineRecoveryApproval(key) { + await unlink(workspaceQuarantineRecoveryPath(key)).catch((error) => { + if (error.code !== "ENOENT") throw error; + }); +} + async function markWorkspaceQuarantined(key, details) { await mkdir(WORKSPACE_LOCK_ROOT, { recursive: true, mode: 0o700 }); const quarantinePath = workspaceQuarantinePath(key); + const quarantineId = randomUUID(); const token = process.pid + "-" + randomUUID(); const temporaryPath = quarantinePath + ".owner-" + token; - await writeFile(temporaryPath, JSON.stringify({ + const record = { ...details, + quarantineId, serverPid: process.pid, processIdentity: await cachedProcessStartIdentity(process.pid), quarantinedAt: new Date().toISOString(), - }), { flag: "wx", mode: 0o600 }); + }; + await writeFile(temporaryPath, JSON.stringify(record), { flag: "wx", mode: 0o600 }); try { - try { await link(temporaryPath, quarantinePath); } - catch (error) { if (error.code !== "EEXIST") throw error; } + try { + await link(temporaryPath, quarantinePath); + } catch (error) { + if (error.code === "EEXIST") { + throw new Error("workspace quarantine marker already exists at " + quarantinePath); + } + throw error; + } } finally { try { await unlink(temporaryPath); } catch (error) { if (error.code !== "ENOENT") throw error; } } - return quarantinePath; + return { quarantinePath, quarantineId, details: record }; } let linuxBootIdPromise = null; @@ -1160,7 +1614,7 @@ async function processStartIdentity(pid) { } 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("powershell.exe", [ + const result = await runCommand(trustedWindowsPowerShell(), [ "-NoProfile", "-NonInteractive", "-Command", script, ], { timeoutMs: 5_000 }); if (result.exitCode === 0 && result.stdout.trim()) { @@ -1201,6 +1655,21 @@ async function serverProcessStartIdentity() { // server processes without a read-then-unlink stale-owner race. const workspaceLocks = new Map(); const quarantinedWorkspaces = new Set(); +async function quarantineLeaseForProcessTree({ + lockKey, workspaceLease, backend, workspacePath, worktreeRoot, terminationError, +}) { + quarantinedWorkspaces.add(lockKey); + workspaceLease.retain(); + const details = { + backend, workspacePath, worktreeRoot, lockRef: workspaceLease.ref, terminationError, + }; + const quarantine = await markWorkspaceQuarantined(lockKey, details); + try { + await workspaceLease.markWorkerQuarantined(quarantine.quarantineId); + } catch { /* the retained lease remains fail-closed; the marker explains recovery */ } + quarantinedWorkspaces.delete(lockKey); + return { quarantinePath: quarantine.quarantinePath, details: quarantine.details }; +} async function withWorkspaceLock(key, lockStoreRoot, fn, { cancel = null, deadline = null, @@ -1208,7 +1677,8 @@ async function withWorkspaceLock(key, lockStoreRoot, fn, { onDeadline = null, isUnavailable = null, onUnavailable = null, - operatorCleared = null, + operatorRecoveryApproved = null, + onAcquired = null, } = {}) { const prev = workspaceLocks.get(key) ?? Promise.resolve(); const prevDone = prev.catch(() => {}); @@ -1250,7 +1720,7 @@ async function withWorkspaceLock(key, lockStoreRoot, fn, { key, cancel, deadline, - operatorCleared, + operatorRecoveryApproved, ownerIdentity: await serverProcessStartIdentity(), processIdentityProbe: processStartIdentity, }); @@ -1263,6 +1733,7 @@ async function withWorkspaceLock(key, lockStoreRoot, fn, { } throw error; } + if (typeof onAcquired === "function") await onAcquired(lease); return await fn(lease); } finally { try { @@ -1360,7 +1831,7 @@ function lockDeadlineDelegation({ function quarantinedDelegation({ backend, workspacePath, worktreeRoot, spec }, sharedQuarantine = null) { return { ok: false, - error: "this workspace is quarantined because a previous worker process tree could not be confirmed terminated; inspect leftover processes, then remove the reported quarantine file deliberately", + 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, @@ -1389,7 +1860,7 @@ function cancelledWorkspaceStatus(id, { workspacePath = "", worktreeRoot = "" } function quarantinedWorkspaceStatus(id, { workspacePath = "", worktreeRoot = "" } = {}, sharedQuarantine = null) { const out = { ok: false, - error: "workspace status is unavailable because an earlier worker process tree could not be confirmed terminated", + 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, @@ -1417,6 +1888,48 @@ async function delegateTask(rawArgs, cancel) { if (typeof rawArgs.task !== "string" || !rawArgs.task.trim()) { throw new Error("task must be a non-empty string"); } + if (!supportsReliableProcessContainment()) { + return { + ok: false, + error: "delegate_task is unsupported on " + RUNTIME_PLATFORM + + ": reliable descendant containment is available only on Windows and Linux", + 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), + }; + } + const backendCommand = await resolvePathCommand(spec.command); + 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), + }; + } const timeoutMs = Number.isInteger(rawArgs.timeoutMs) ? Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, rawArgs.timeoutMs)) : DEFAULT_TIMEOUT_MS; @@ -1425,6 +1938,7 @@ async function delegateTask(rawArgs, cancel) { let worktreeRoot = ""; let gitCommonDir = ""; let lockStoreRoot = ""; + let repositoryAccess = null; try { if (cancel?.cancelled) { return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); @@ -1434,8 +1948,12 @@ async function delegateTask(rawArgs, cancel) { await requireGitRepo(workspacePath, { cancel, deadline }); worktreeRoot = await gitWorktreeRoot(workspacePath, { cancel, deadline }); gitCommonDir = await gitCommonDirectory(workspacePath, { cancel, deadline }); - lockStoreRoot = await ensureWorkspaceLockStore(gitCommonDir, { cancel, deadline }); + repositoryAccess = await openRepositoryAccess(gitCommonDir, { cancel, deadline }); + const lockStore = await ensureWorkspaceLockStore(repositoryAccess.commonDir, { cancel, deadline }); + lockStoreRoot = lockStore.root; + repositoryAccess.key = repositoryLockKey(gitCommonDir, lockStore.repositoryId); } catch (error) { + await repositoryAccess?.close().catch(() => {}); if (error instanceof OperationCancelledError) { return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); } @@ -1450,13 +1968,14 @@ async function delegateTask(rawArgs, cancel) { } throw error; } - const lockKey = repositoryLockKey(gitCommonDir); - const existingQuarantine = await readWorkspaceQuarantine(lockKey); - if (quarantinedWorkspaces.has(lockKey) || existingQuarantine) { - return quarantinedDelegation({ backend, workspacePath, worktreeRoot, spec }, existingQuarantine); - } - let observedQuarantine = null; - return await withWorkspaceLock(lockKey, lockStoreRoot, async (workspaceLease) => { + const lockKey = repositoryAccess.key; + try { + const existingQuarantine = await readWorkspaceQuarantine(lockKey); + 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(lockKey); if (quarantinedWorkspaces.has(lockKey) || sharedQuarantine) { return quarantinedDelegation({ backend, workspacePath, worktreeRoot, spec }, sharedQuarantine); @@ -1464,14 +1983,34 @@ async function delegateTask(rawArgs, cancel) { if (cancel && cancel.cancelled) { return cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }); } + let gitProcessQuarantine = null; + const quarantineGitProcessTree = async ({ label, terminationError }) => { + gitProcessQuarantine ??= await quarantineLeaseForProcessTree({ + lockKey, + workspaceLease, + backend: label, + workspacePath, + worktreeRoot, + terminationError, + }); + return gitProcessQuarantine; + }; const allowDirty = rawArgs.allowDirty === true; // Attribution window for concurrency disclosure: everything between the // before-snapshot and the after-snapshot. const attributionWindowStart = Date.now(); let before; try { - before = await gitSnapshot(worktreeRoot, { cancel, deadline, ownLockRef: workspaceLease.ref }); + 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 }); } @@ -1594,7 +2133,7 @@ async function delegateTask(rawArgs, cancel) { ownershipLostError ??= error; }); let workerLockUpdate = Promise.resolve(); - const result = await runCommand(spec.command, args, { + const result = await runCommand(backendCommand, args, { cwd: workspacePath, timeoutMs: remaining, manageProcessTree: true, @@ -1629,20 +2168,22 @@ async function delegateTask(rawArgs, cancel) { workspaceLease.retain(); // Persist the operator-visible marker before making the retained lease // recoverable. If persistence fails, the lease remains in the - // unreclaimable running state instead of treating a never-created marker - // as one that an operator deliberately removed. - quarantinePath = await markWorkspaceQuarantined(lockKey, { + // unreclaimable running state instead of mistaking temporary-file cleanup + // for an explicit operator recovery authorization. + const quarantine = await markWorkspaceQuarantined(lockKey, { backend, workspacePath, worktreeRoot, lockRef: workspaceLease.ref, terminationError: result.terminationError, }); + quarantinePath = quarantine.quarantinePath; try { - await workspaceLease.markWorkerQuarantined(); + await workspaceLease.markWorkerQuarantined(quarantine.quarantineId); } catch { /* running state remains fail-closed; the marker still explains manual recovery */ } - // The shared marker is now authoritative and removable by an operator; - // retain the local fallback only when writing that marker failed. + // 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 { @@ -1670,11 +2211,18 @@ async function delegateTask(rawArgs, cancel) { cancel, deadline, ownLockRef: workspaceLease.ref, + lockStoreRoot, concurrencyWindowStart: attributionWindowStart, + onUnconfirmedProcessTree: quarantineGitProcessTree, }); commits = await committedDelta(worktreeRoot, before, after, { cancel, deadline }); } catch (error) { - if (error instanceof OperationCancelledError) { + 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; @@ -1695,7 +2243,7 @@ async function delegateTask(rawArgs, cancel) { ); let error = ""; if (!result.treeTerminated) { - error = "backend process tree could not be confirmed terminated; the shared workspace quarantine remains until an operator checks for leftovers and removes quarantinePath"; + 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) { @@ -1735,20 +2283,24 @@ async function delegateTask(rawArgs, cancel) { commits, experimental: Boolean(spec.experimental), }; - }, { - cancel, - deadline, - onCancelled: () => cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }), - onDeadline: () => lockDeadlineDelegation({ backend, workspacePath, worktreeRoot, spec }), - operatorCleared: () => quarantineFileAbsent(lockKey), - isUnavailable: async () => { - observedQuarantine = await readWorkspaceQuarantine(lockKey); - return quarantinedWorkspaces.has(lockKey) || Boolean(observedQuarantine); - }, - onUnavailable: () => quarantinedDelegation( - { backend, workspacePath, worktreeRoot, spec }, observedQuarantine, - ), - }); + }, { + cancel, + deadline, + onCancelled: () => cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }), + onDeadline: () => lockDeadlineDelegation({ backend, workspacePath, worktreeRoot, spec }), + operatorRecoveryApproved: (owner) => quarantineRecoveryApproved(lockKey, owner), + onAcquired: () => clearQuarantineRecoveryApproval(lockKey), + isUnavailable: async () => { + observedQuarantine = await readWorkspaceQuarantine(lockKey); + return quarantinedWorkspaces.has(lockKey) || Boolean(observedQuarantine); + }, + onUnavailable: () => quarantinedDelegation( + { backend, workspacePath, worktreeRoot, spec }, observedQuarantine, + ), + }); + } finally { + await repositoryAccess.close().catch(() => {}); + } } function textResult(header, obj) { @@ -1799,6 +2351,7 @@ function jsonRpcError(id, code, message) { return { jsonrpc: "2.0", id, error: { // 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; @@ -1824,17 +2377,24 @@ async function terminateActiveRequests(reason = "shutdown") { await Promise.allSettled(entries.map((entry) => entry.done)); } -function installShutdownHandlers(stdin) { - let shuttingDown = false; +function installShutdownHandlers(stdin, stdout = process.stdout) { const shutdown = (exitCode) => { - if (shuttingDown) return; - shuttingDown = true; + 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", () => { @@ -1898,12 +2458,29 @@ async function handleMessage(message) { } } if (params.name === "workspace_status") { + if (!supportsReliableProcessContainment()) { + const out = { + ok: false, + error: "workspace_status is unsupported on " + RUNTIME_PLATFORM + + ": reliable Git helper containment is available only on Windows and Linux", + 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 repositoryAccess = null; try { workspacePath = await validateWorkspace(args.workspacePath, { cancel }); if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath }); @@ -1912,7 +2489,10 @@ async function handleMessage(message) { if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath }); worktreeRoot = await gitWorktreeRoot(workspacePath, { cancel }); gitCommonDir = await gitCommonDirectory(workspacePath, { cancel }); - lockStoreRoot = await ensureWorkspaceLockStore(gitCommonDir, { cancel }); + repositoryAccess = await openRepositoryAccess(gitCommonDir, { cancel }); + const lockStore = await ensureWorkspaceLockStore(repositoryAccess.commonDir, { cancel }); + lockStoreRoot = lockStore.root; + repositoryAccess.key = repositoryLockKey(gitCommonDir, lockStore.repositoryId); } catch (error) { if (error instanceof OperationCancelledError) { return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); @@ -1920,7 +2500,7 @@ async function handleMessage(message) { throw error; } if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); - const lockKey = repositoryLockKey(gitCommonDir); + const lockKey = repositoryAccess.key; const existingQuarantine = await readWorkspaceQuarantine(lockKey); if (quarantinedWorkspaces.has(lockKey) || existingQuarantine) { return quarantinedWorkspaceStatus( @@ -1928,15 +2508,30 @@ async function handleMessage(message) { ); } let observedQuarantine = null; - return await withWorkspaceLock(lockKey, lockStoreRoot, async () => { + return await withWorkspaceLock(lockKey, lockStoreRoot, async (workspaceLease) => { const sharedQuarantine = await readWorkspaceQuarantine(lockKey); if (quarantinedWorkspaces.has(lockKey) || sharedQuarantine) { return quarantinedWorkspaceStatus( message.id, { workspacePath, worktreeRoot }, sharedQuarantine, ); } + let gitProcessQuarantine = null; + const quarantineGitProcessTree = async ({ label, terminationError }) => { + gitProcessQuarantine ??= await quarantineLeaseForProcessTree({ + lockKey, + workspaceLease, + backend: label, + workspacePath, + worktreeRoot, + terminationError, + }); + return gitProcessQuarantine; + }; try { - const git = await gitSnapshot(worktreeRoot, { cancel }); + const git = await gitSnapshot(worktreeRoot, { + cancel, ownLockRef: workspaceLease.ref, lockStoreRoot, + onUnconfirmedProcessTree: quarantineGitProcessTree, + }); if (cancel.cancelled) { return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); } @@ -1945,6 +2540,11 @@ async function handleMessage(message) { 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 }); } @@ -1953,7 +2553,8 @@ async function handleMessage(message) { }, { cancel, onCancelled: () => cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }), - operatorCleared: () => quarantineFileAbsent(lockKey), + operatorRecoveryApproved: (owner) => quarantineRecoveryApproved(lockKey, owner), + onAcquired: () => clearQuarantineRecoveryApproval(lockKey), isUnavailable: async () => { observedQuarantine = await readWorkspaceQuarantine(lockKey); return quarantinedWorkspaces.has(lockKey) || Boolean(observedQuarantine); @@ -1963,6 +2564,7 @@ async function handleMessage(message) { ), }); } finally { + await repositoryAccess?.close().catch(() => {}); finishRequest(); } } @@ -1994,9 +2596,14 @@ function startStdioServer({ stdin = process.stdin, stdout = process.stdout } = { 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; + } const line = buffer.slice(0, newlineIndex).trim(); buffer = buffer.slice(newlineIndex + 1); newlineIndex = buffer.indexOf("\n"); @@ -2019,5 +2626,5 @@ function startStdioServer({ stdin = process.stdin, stdout = process.stdout } = { if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) { startStdioServer(); - installShutdownHandlers(process.stdin); + installShutdownHandlers(process.stdin, process.stdout); } 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 index 0b1e9c1..73d9a94 100644 --- a/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -60,7 +60,8 @@ inside the target git repository, and their results come back as a git diff for - 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 remove the reported quarantinePath. The workspace may + 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, so still review the returned snapshot. - 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 @@ -74,7 +75,8 @@ inside the target git repository, and their results come back as a git diff for - 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 removes the quarantine marker. + moved to the quarantined state is reclaimable once the operator performs that explicit approval + rename. ## Notes diff --git a/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs index 059cc1e..ea51f8d 100644 --- a/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs @@ -112,10 +112,13 @@ test("workspace_status reports changed files including untracked ones", async () }); test("delegate_task refuses a dirty tree without allowDirty and sets isError", async () => { - await withServer({}, async (s) => { + 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: "claude", task: "x", workspacePath: repo } }); + 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/); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs index 22edf7b..91a0033 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs @@ -8,6 +8,10 @@ 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"); @@ -67,6 +71,21 @@ if (spec.branchRoundTrip) { 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.mirrorPush) { event("start"); execFileSync("git", ["push", "--mirror", spec.remotePath]); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs index 0e4fbba..03e3f71 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs @@ -6,8 +6,10 @@ import test from "node:test"; import { isProcessTreeAlive, + initializeProcessTree, linuxProcessGroupHasLiveMembers, parsePosixProcessLine, + posixProcessSnapshot, refreshProcessTree, signalProcessTree, waitForProcessTreeExit, @@ -69,7 +71,7 @@ test("Linux ancestry refresh follows task children without scanning all of procf }; const treeState = { knownPids: new Set([601]), knownStarts: new Map() }; const snapshot = await refreshProcessTree({ pid: 601 }, treeState, { - platform: "linux", procRoot, fsOps, + 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"); @@ -96,6 +98,97 @@ test("Linux refresh recovers a marked detached child after its parent exits", as "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 })); @@ -178,7 +271,9 @@ test("zombie-only groups count as exited only for the final post-SIGKILL wait", 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]) }; + const treeState = { + knownPids: new Set([501]), knownStarts: new Map([[501, "501"]]), + }; const probeProcessGroup = () => {}; const common = { platform: "linux", procRoot, probeProcessGroup }; @@ -196,12 +291,14 @@ function snapshotOf(processes) { test("the process group is signaled only while its leader identity is original", async () => { const child = { pid: 9001 }; - const treeState = { knownPids: new Set([9001, 9002]), knownStarts: new Map() }; + 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 }); + await refreshProcessTree(child, treeState, { + platform: "linux", posixProcessSnapshot: original, allowRootIdentityCapture: true, + }); const groupSignals = []; const oneSignals = []; @@ -254,6 +351,447 @@ test("a reused POSIX leader cannot contribute unrelated descendants", async () = 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 = { @@ -274,16 +812,16 @@ test("tracked POSIX PIDs absent from a successful signal snapshot are skipped", test("windows tree inspection drops known PIDs whose creation identity changed", async () => { const treeState = { knownPids: new Set([500, 501]), - knownStarts: new Map([[500, "ticks-1"], [501, "ticks-2"]]), + 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: "ticks-REUSED" }, - { ProcessId: 501, ParentProcessId: 500, CreationTicks: "ticks-2" }, - { ProcessId: 502, ParentProcessId: 501, CreationTicks: "ticks-3" }, + { ProcessId: 500, ParentProcessId: 1, CreationTicks: "300" }, + { ProcessId: 501, ParentProcessId: 500, CreationTicks: "200" }, + { ProcessId: 502, ParentProcessId: 501, CreationTicks: "400" }, ]), stderr: "", }; @@ -292,7 +830,108 @@ test("windows tree inspection drops known PIDs whose creation identity changed", 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), "ticks-1", "the original identity is retained for comparison"); + 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 () => { @@ -301,6 +940,7 @@ test("the group is still signaled when process enumeration is unavailable", asyn 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, { @@ -312,3 +952,15 @@ test("the group is still signaled when process enumeration is unavailable", asyn 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 index b6afe6f..b4ba18b 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { access, chmod, copyFile, mkdir, mkdtemp, readFile, realpath, rm, symlink, unlink, writeFile } from "node:fs/promises"; +import { access, chmod, copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { createInterface } from "node:readline"; @@ -9,7 +9,12 @@ import test from "node:test"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; -import { workspaceLockRef } from "../workspace-lock.mjs"; +import { + localHostIdentity, WORKSPACE_LOCK_REF_PREFIX, workspaceLockRef, +} from "../workspace-lock.mjs"; +import { + closestExistingBase, populateCommitishCache, runCommand, runGitCommand, +} from "../server.mjs"; const execFileAsync = promisify(execFile); const testsRoot = path.dirname(fileURLToPath(import.meta.url)); @@ -18,6 +23,623 @@ const serverPath = path.join(pluginRoot, "server.mjs"); const fakeBackendPath = path.join(testsRoot, "fake-backend.mjs"); const requestKey = (id) => typeof id + ":" + String(id); +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("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("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("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 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); +}); + function currentUserLockRoot() { const user = os.userInfo(); const identity = Number.isInteger(user.uid) && user.uid >= 0 @@ -36,14 +658,24 @@ async function coordinationLockStore(workspace) { return path.join(await canonicalGitCommonDirectory(workspace), "cli-agent-bridge-lock-store.git"); } -function repositoryStatePaths(canonicalGitCommonDir) { - const normalized = path.normalize(canonicalGitCommonDir); - const key = "git-common-dir:" + normalized; +async function repositoryKey(canonicalGitCommonDir) { + if (process.platform === "linux") { + const repositoryId = (await readFile(path.join( + canonicalGitCommonDir, "cli-agent-bridge-lock-store.git", "cli-agent-bridge-repository-id", + ), "utf8")).trim(); + return "git-common-dir-id:" + repositoryId; + } + return "git-common-dir:" + path.normalize(canonicalGitCommonDir); +} + +async function repositoryStatePaths(canonicalGitCommonDir) { + const key = await repositoryKey(canonicalGitCommonDir); const digest = createHash("sha256").update(key).digest("hex"); const root = currentUserLockRoot(); return { root, quarantinePath: path.join(root, digest + ".quarantine"), + recoveryPath: path.join(root, digest + ".quarantine.recovery-approved"), }; } @@ -127,14 +759,7 @@ async function makeHarness(context, { unborn = false } = {}) { const tempRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-bridge-test-")); const workspace = path.join(tempRoot, "workspace"); await mkdir(workspace); - 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 }); - } + await initializeFixtureRepository(workspace, { unborn }); const configPath = path.join(tempRoot, "backends.json"); await writeFile(configPath, JSON.stringify({ backends: { @@ -156,6 +781,17 @@ async function makeHarness(context, { unborn = false } = {}) { 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", @@ -194,6 +830,85 @@ test("Codex templates delimit option-looking task text", async () => { assert.match(source, /resumeArgs: \["exec", "resume", "", "--", ""\]/u); }); +test("unsupported POSIX platforms fail before probing Git or a backend", async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), "cli-agent-unsupported-platform-test-")); + const workspace = path.join(root, "workspace"); + const marker = path.join(workspace, "backend-started.txt"); + const sentinel = path.join(workspace, "sentinel.txt"); + const configPath = path.join(root, "backends.json"); + await mkdir(workspace); + await writeFile(sentinel, "unchanged\n"); + 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: "darwin", + }); + await client.initialize(); + context.after(async () => { + await client.close(); + await rm(root, { recursive: true, force: true }); + }); + + const listed = await client.request("tools/call", { + name: "list_backends", arguments: {}, + }); + const backend = listed.result.structuredContent.backends[0]; + assert.equal(backend.available, false); + assert.equal(backend.version, null); + assert.match(backend.error, /unsupported platform/iu); + + const delegated = await client.request("tools/call", taskArguments(workspace, { name: "run" })); + const out = delegated.result.structuredContent; + assert.equal(out.ok, false); + assert.match(out.error, /unsupported on darwin/iu); + assert.deepEqual(await readdir(workspace), ["sentinel.txt"]); + assert.equal(await readFile(sentinel, "utf8"), "unchanged\n"); + await assert.rejects(access(marker), /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, { @@ -240,10 +955,92 @@ test("the private lock store inherits the repository sharing mode", async (conte "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 identityMode = (await stat( + path.join(lockStore, "cli-agent-bridge-repository-id"), + )).mode & 0o777; + assert.equal(identityMode & 0o060, 0o060, "the repository group must be able to read/write its identity"); + } }); -test("Git snapshot commands terminate escaped hook descendants", { - skip: process.platform !== "linux" ? "Linux /proc marker containment fixture" : false, +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 readFile( + path.join(store, "cli-agent-bridge-repository-id"), "utf8", + )).trim(); + 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.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("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 readFile(path.join( + firstCommonDir, "cli-agent-bridge-lock-store.git", "cli-agent-bridge-repository-id", + ), "utf8")).trim(); + 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 readFile(path.join( + secondCommonDir, "cli-agent-bridge-lock-store.git", "cli-agent-bridge-repository-id", + ), "utf8")).trim(); + 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"); @@ -268,11 +1065,52 @@ test("Git snapshot commands terminate escaped hook descendants", { const response = await client.request("tools/call", { name: "workspace_status", arguments: { workspacePath: workspace }, }); - assert.equal(response.result.structuredContent.ok, true, client.stderr); - await access(ready); + 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(() => unlink(out.quarantinePath).catch(() => {})); + } + 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, - "the fsmonitor descendant must be dead before the workspace lease is released"); + "a detached clean-filter descendant must not outlive the snapshot command"); }); test("dirty checks override submodule ignore configuration", async (context) => { @@ -472,9 +1310,12 @@ test("a cross-process lock waiter obeys the delegation deadline", async (context 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 normalized = path.normalize(canonicalRoot); - const key = "git-common-dir:" + normalized; + const key = await repositoryKey(canonicalRoot); const ref = workspaceLockRef(key); const lockStore = await coordinationLockStore(workspace); const eventFile = path.join(tempRoot, "lost-lock-events.jsonl"); @@ -520,7 +1361,10 @@ test("losing a Git-ref lease never strands the local FIFO gate", async (context) }); test("unconfirmed termination after lease loss quarantines delegation and status", { - skip: process.platform !== "win32", + // 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"); @@ -542,10 +1386,9 @@ test("unconfirmed termination after lease loss quarantines delegation and status try { await client.initialize(); const canonicalRoot = await canonicalGitCommonDirectory(workspace); - ({ quarantinePath } = repositoryStatePaths(canonicalRoot)); + ({ quarantinePath } = await repositoryStatePaths(canonicalRoot)); context.after(() => rm(quarantinePath, { force: true })); - const normalized = path.normalize(canonicalRoot); - const key = "git-common-dir:" + normalized; + const key = await repositoryKey(canonicalRoot); ref = workspaceLockRef(key); lockStore = await coordinationLockStore(workspace); const delegated = client.request("tools/call", taskArguments(workspace, { @@ -616,7 +1459,11 @@ test("a quarantine marker blocks delegations in every server process", async (co const secondClient = new McpClient(configPath); try { await secondClient.initialize(); - const { root, quarantinePath } = repositoryStatePaths( + 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 }); @@ -634,6 +1481,67 @@ test("a quarantine marker blocks delegations in every server process", async (co } }); +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, { force: true })); + context.after(() => rm(recoveryPath, { 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: 700 }), 132); + assert.equal(absentOnly.result.structuredContent.ok, false); + 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 writeFile(quarantinePath, 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"); @@ -736,7 +1644,7 @@ test("cancellation terminates descendants before returning", async (context) => 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); + 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", })); @@ -792,6 +1700,28 @@ test("a detached child remains contained when its parent exits before ancestry p 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"); @@ -840,7 +1770,9 @@ test("commits on a new branch are reported when the worker returns to the origin commitMessage: "commit outside final HEAD", })); const out = response.result.structuredContent; - assert.equal(out.ok, true, JSON.stringify(out.error)); + 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"); @@ -1000,6 +1932,70 @@ test("closing MCP stdin terminates active worker descendants", async (context) = 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. @@ -1049,6 +2045,27 @@ test("a worker ref pointing at a non-commit object is reported without failing t assert.match(out.commits.log, /non-commit object/u); }); +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, workspace, client } = await makeHarness(context); const refName = "refs/tags/blob-to-commit"; @@ -1129,6 +2146,47 @@ test("linked worktrees sharing Git refs serialize across server processes", asyn } }); +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("a commit on the checked-out branch is reported exactly once", async (context) => { const { workspace, client } = await makeHarness(context); @@ -1240,6 +2298,35 @@ test("fetched remote history is excluded from worker-created commits", async (co assert.match(out.commits.diffStat, /worker-after-fetch\.txt/u); }); +test("fetch history directed into a local ref remains an external baseline", async (context) => { + const { tempRoot, workspace, client } = await makeHarness(context); + 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(workspace, { + 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.newCommitCount, 1, out.commits.log); + assert.match(out.commits.log, /worker commit after local-ref fetch/u); + assert.doesNotMatch(out.commits.log, /externally fetched local-ref commit/u); + assert.match(out.commits.log, /FETCH_HEAD recorded externally fetched history/u); + assert.ok(out.commits.refsChanged.some((item) => item.ref === "refs/heads/imported-upstream"), + JSON.stringify(out.commits.refsChanged)); + assert.doesNotMatch(out.commits.diffStat, /external-local-ref\.txt/u); + assert.match(out.commits.diffStat, /worker-after-local-fetch\.txt/u); +}); + test("a fetched tag tip is an external baseline for later worker commits", async (context) => { const { workspace, client } = await makeHarness(context); const response = await client.request("tools/call", taskArguments(workspace, { @@ -1281,7 +2368,12 @@ test("workspace lock metadata is absent from mirrored repository refs", async (c const response = await client.request("tools/call", taskArguments(workspace, { name: "mirror", mirrorPush: true, remotePath: mirror, })); - assert.equal(response.result.structuredContent.ok, true, response.result.structuredContent.error); + 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(() => unlink(out.quarantinePath).catch(() => {})); + } const { stdout: mirroredRefs } = await execFileAsync( "git", ["for-each-ref", "--format=%(refname)"], { cwd: mirror }, ); @@ -1324,7 +2416,7 @@ test("list_backends can be cancelled while a version probe hangs", async (contex assert.ok(Array.isArray(response.result.structuredContent.backends)); }); -test("backend spawn failures report the launch error", { +test("missing backend commands fail before workspace launch", { skip: process.platform === "win32", }, async (context) => { const { workspace, configPath, client } = await makeHarness(context); @@ -1345,7 +2437,7 @@ test("backend spawn failures report the launch error", { }); const out = response.result.structuredContent; assert.equal(out.ok, false); - assert.match(out.error, /failed to start.*ENOENT/iu); + assert.match(out.error, /command was not found or is not executable/iu); assert.doesNotMatch(out.error, /exited with code null/iu); }); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs index e52a8de..b122c7d 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs @@ -367,9 +367,8 @@ test("worker state updates honour the delegation cancellation and deadline", asy await assert.rejects(execFileAsync("git", ["rev-parse", "--verify", workspaceLockRef(key)], { cwd: repo }), /Command failed/u); }); -test("initial acquisition CAS obeys cancellation while a Git hook blocks", { - // This fixture depends on Linux's executable-hook and process interruption - // semantics. macOS Git installations may disable or sandbox this hook path. +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); @@ -386,42 +385,16 @@ test("initial acquisition CAS obeys cancellation while a Git hook blocks", { ].join("\n")); await chmod(hook, 0o755); context.after(() => writeFile(release, "release\n").catch(() => {})); - - const listeners = new Set(); - let resolveCancelled; - const cancel = { - cancelled: false, - promise: new Promise((resolve) => { resolveCancelled = resolve; }), - subscribe(listener) { - listeners.add(listener); - return () => { listeners.delete(listener); }; - }, - cancel() { - this.cancelled = true; - resolveCancelled(); - for (const listener of [...listeners]) listener(); - }, - }; - const acquisition = tryAcquireGitWorkspaceLock({ - cwd: repo, key: "git-worktree:" + repo, cancel, heartbeatMs: 60_000, + const acquisition = await tryAcquireGitWorkspaceLock({ + cwd: repo, key: "git-worktree:" + repo, heartbeatMs: 60_000, }); - const readyDeadline = Date.now() + 3_000; - while (true) { - try { await access(ready); break; } - catch { - if (Date.now() >= readyDeadline) throw new Error("reference-transaction hook did not start"); - await new Promise((resolve) => setTimeout(resolve, 20)); - } - } - const cancelledAt = Date.now(); - cancel.cancel(); - await assert.rejects(acquisition, WorkspaceLockCancelledError); - assert.ok(Date.now() - cancelledAt < 1_500, - "cancellation must interrupt the update-ref CAS instead of waiting for the hook timeout"); - await writeFile(release, "release\n"); + 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("quarantined leases are reclaimable after the operator clears the marker", async (context) => { +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); @@ -433,6 +406,7 @@ test("quarantined leases are reclaimable after the operator clears the marker", ownerPid: process.pid, workerState: "quarantined", quarantineMarkerPersisted: true, + quarantineId: "quarantine-incident-1", workerPid: 4242, acquiredAt: now, heartbeatAt: now, @@ -440,15 +414,15 @@ test("quarantined leases are reclaimable after the operator clears the marker", const stillHeld = await tryAcquireGitWorkspaceLock({ cwd: repo, key, now, staleMs: 60_000, processProbe: () => "alive", - operatorCleared: () => false, + operatorRecoveryApproved: () => false, }); assert.deepEqual(stillHeld, { acquired: false, reason: "held" }); const reclaimed = await tryAcquireGitWorkspaceLock({ cwd: repo, key, now, staleMs: 60_000, processProbe: () => "alive", - operatorCleared: () => true, + operatorRecoveryApproved: (owner) => owner.quarantineId === "quarantine-incident-1", }); - assert.equal(reclaimed.acquired, true, "a removed quarantine marker authorizes takeover"); + assert.equal(reclaimed.acquired, true, "a matching durable approval authorizes takeover"); await reclaimed.lease.release(); }); @@ -468,10 +442,10 @@ test("a quarantined lease without proof of a durable marker fails closed", async }); const result = await tryAcquireGitWorkspaceLock({ cwd: repo, key, now, staleMs: 30_000, processProbe: () => "dead", - operatorCleared: () => true, + operatorRecoveryApproved: () => true, }); assert.deepEqual(result, { acquired: false, reason: "held" }, - "an absent marker is not operator clearance unless persistence was recorded"); + "approval is invalid unless marker persistence and an incident id were recorded"); }); test("a different OS user cannot clear another user's quarantined lease", async (context) => { @@ -486,18 +460,19 @@ test("a different OS user cannot clear another user's quarantined lease", async 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", - operatorCleared: () => true, + operatorRecoveryApproved: () => true, }); assert.deepEqual(result, { acquired: false, reason: "held" }); }); -test("a quarantined lease left by a crashed owner is reclaimable after the stale window", async (context) => { +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(); @@ -508,14 +483,15 @@ test("a quarantined lease left by a crashed owner is reclaimable after the stale 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", - operatorCleared: () => false, + operatorRecoveryApproved: () => false, }); - assert.equal(result.acquired, true, "crash fallback: stale heartbeat plus dead owner"); - await result.lease.release(); + 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 index 85606c2..4e083fe 100644 --- a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs @@ -3,6 +3,8 @@ import { createHash, randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import os from "node:os"; +import { safeGitInvocation } from "./git-executable.mjs"; + export const WORKSPACE_LOCK_REF_PREFIX = "refs/cli-agent-bridge/workspace-locks/"; const WORKSPACE_HISTORY_REF_SUFFIX = ".history"; const WORKSPACE_RECOVERY_REF_SUFFIX = ".recovery"; @@ -149,9 +151,11 @@ async function runGit(cwd, args, { checkInterrupted(cancel, deadline); const remaining = deadline === null ? GIT_TIMEOUT_MS : deadline - Date.now(); const timeoutMs = Math.max(1, Math.min(GIT_TIMEOUT_MS, remaining)); + const git = await safeGitInvocation(args); const result = await new Promise((resolve) => { - const child = spawn("git", args, { + const child = spawn(git.command, git.args, { cwd, + env: git.env, windowsHide: true, stdio: [stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"], }); @@ -278,26 +282,26 @@ async function canReclaim(owner, { hostIdentity, processProbe, processIdentityProbe = null, - operatorCleared = 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 once the quarantine - // marker is deliberately removed (or, failing that, the owner died and the - // heartbeat went stale after a crash). + // 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 (operatorCleared) { + if (typeof owner.quarantineId === "string" && owner.quarantineId && operatorRecoveryApproved) { try { - if (await operatorCleared()) return true; - } catch { /* treat a failed check as not cleared */ } + if (await operatorRecoveryApproved(owner)) return true; + } catch { /* treat a failed check as not approved */ } } - return now - owner.heartbeatAt >= staleMs && - await originalOwnerStatus(owner, processProbe, processIdentityProbe) === "dead"; + return false; } if (now - owner.heartbeatAt < staleMs) return false; if (await originalOwnerStatus(owner, processProbe, processIdentityProbe) !== "dead") return false; @@ -432,8 +436,13 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { async markWorkerIdle(interrupt = {}) { await queueUpdate({ workerState: "idle", workerPid: null }, interrupt); }, - async markWorkerQuarantined() { - await queueUpdate({ workerState: "quarantined", quarantineMarkerPersisted: true }); + 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; @@ -509,7 +518,7 @@ export async function tryAcquireGitWorkspaceLock({ now = Date.now(), processProbe = probeProcess, processIdentityProbe = null, - operatorCleared = null, + operatorRecoveryApproved = null, } = {}) { checkInterrupted(cancel, deadline); const ref = workspaceLockRef(key); @@ -522,7 +531,7 @@ export async function tryAcquireGitWorkspaceLock({ recovery.owner.ownerToken === current.owner?.token, ); if (current && !sharedRecoveryAuthorized && !await canReclaim(current.owner, { - now, staleMs, hostIdentity, processProbe, processIdentityProbe, operatorCleared, + now, staleMs, hostIdentity, processProbe, processIdentityProbe, operatorRecoveryApproved, })) { return { acquired: false, reason: "held" }; } From 95af068ee802b4354242ca10479fef3d7ffcf823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Mon, 17 Aug 2026 07:22:24 +0800 Subject: [PATCH 30/40] fix(cli-agent-bridge): close latest runtime review gaps --- plugins/Hylouis233/cli-agent-bridge/README.md | 13 +- .../cli-agent-bridge/git-executable.mjs | 14 +- .../cli-agent-bridge/process-tree.mjs | 124 ++++++++++++ .../Hylouis233/cli-agent-bridge/server.mjs | 182 +++++++++++++---- .../tests/process-tree.test.mjs | 94 +++++++++ .../cli-agent-bridge/tests/server.test.mjs | 189 ++++++++++++++++-- 6 files changed, 556 insertions(+), 60 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 205c4b9..6264706 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -123,8 +123,9 @@ you already obtained a valid ID from that backend outside this Plugin. 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. - On Linux, the private store carries an atomically initialized persistent repository identity, - and the bridge keeps a common-directory handle open through release. Renaming the repository + On Linux, 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 @@ -142,7 +143,9 @@ you already obtained a valid ID from that backend outside this Plugin. 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. Periodic ownership checks read that ref - without manufacturing new heartbeat blobs, and each normal release schedules Git's safe automatic + without manufacturing new heartbeat blobs; any extant `starting`/`running` ref remains a + conservative attribution-overlap signal regardless of its last state-transition timestamp. + 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 @@ -155,7 +158,9 @@ you already obtained a valid ID from that backend outside this Plugin. leader identity still matches. On Linux, descendants also inherit a per-run environment marker; if the parent exits before ancestry polling, the close path uses a bounded observation grace and marker scans recover children that become visible just after the leader exits, using stable - identities from `/proc`. Under extreme Linux process churn, if an identity-stable ancestry + identities from `/proc`. If the kernel exposes tasks but not their `children` files, a verified + startup capability check switches to a full PID/PPID snapshot while preserving the same marker + and immutable-identity checks. Under extreme Linux process churn, if an identity-stable ancestry sample cannot be completed, the bridge conservatively quarantines the workspace for the same operator-verified manual recovery described below. Repository discovery and read-only snapshots resolve Git before entering the workspace and explicitly disable repository hooks, fsmonitor, diff --git a/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs b/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs index 6a877d3..c78b3dc 100644 --- a/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs @@ -74,7 +74,19 @@ async function resolvePathCommandUncached(command) { export function resolvePathCommand(command) { if (typeof command !== "string" || !command) return Promise.resolve(null); if (!pathCommandPromises.has(command)) { - pathCommandPromises.set(command, resolvePathCommandUncached(command)); + const resolution = resolvePathCommandUncached(command); + pathCommandPromises.set(command, resolution); + // Share an in-flight lookup and retain positive results, but do not make a + // missing/not-yet-installed CLI permanent for the lifetime of the server. + // The identity guard prevents an older completion from deleting a newer + // retry that has already occupied the same cache slot. + void resolution.then((resolved) => { + if (resolved === null && pathCommandPromises.get(command) === resolution) { + pathCommandPromises.delete(command); + } + }, () => { + if (pathCommandPromises.get(command) === resolution) pathCommandPromises.delete(command); + }); } return pathCommandPromises.get(command); } diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs index ada4191..80617ad 100644 --- a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs @@ -304,6 +304,102 @@ async function linuxMarkedProcesses(marker, procRoot, fsOps) { 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. @@ -315,6 +411,11 @@ async function linuxTrackedProcessSnapshot( 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) => { @@ -450,6 +551,29 @@ async function linuxTrackedProcessSnapshot( `${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; diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index e48c754..4e4714e 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -495,9 +495,19 @@ export async function runCommand(command, args, options = {}) { try { child.kill("SIGKILL"); } catch { /* already gone */ } }); if (process.platform !== "win32" || options.refreshProcessTree) { - const refreshIntervalMs = process.platform === "linux" ? 25 : 250; - treeRefreshTimer = setInterval(() => { void refreshTree(); }, refreshIntervalMs); - treeRefreshTimer.unref?.(); + // 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. @@ -794,18 +804,26 @@ async function openRepositoryAccess(gitCommonDir, options = {}) { 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; -async function readRepositoryId(idPath, options = {}) { +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"); } - const value = (await interruptibleFilesystemOperation(readFile(idPath, "utf8"), options)).trim(); - if (!REPOSITORY_ID_PATTERN.test(value)) { - throw new Error("workspace lock store repository identity is malformed"); - } - return value; + return validateRepositoryId( + await interruptibleFilesystemOperation(readFile(idPath, "utf8"), options), + "legacy identity file", + ); } async function repositoryIdMode(storeRoot, options = {}) { @@ -822,38 +840,126 @@ async function repositoryIdMode(storeRoot, options = {}) { return 0o600; } -async function ensureRepositoryId(storeRoot, options = {}) { - const idPath = path.join(storeRoot, REPOSITORY_ID_FILE); - try { - return await readRepositoryId(idPath, options); - } catch (error) { - if (error.code !== "ENOENT") throw error; +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, + ); + } + } } - const candidateId = randomUUID(); - const candidatePath = idPath + ".candidate-" + String(process.pid) + "-" + randomUUID(); + 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(candidatePath, candidateId + "\n", { flag: "wx", mode }); + const writing = writeFile(idPath, repositoryId + "\n", { flag: "wx", mode }); try { await interruptibleFilesystemOperation(writing, options); + await interruptibleFilesystemOperation(chmod(idPath, mode), options); } catch (error) { - // A cancellation cannot abort the underlying write. Remove a candidate - // that arrives after the request has already stopped waiting for it. - void writing.then(() => unlink(candidatePath)).catch(() => {}); - throw 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 { - await interruptibleFilesystemOperation(chmod(candidatePath, mode), options); - try { - await interruptibleFilesystemOperation(link(candidatePath, idPath), options); - } catch (error) { - if (error.code !== "EEXIST") throw error; + 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"); } - } finally { - await unlink(candidatePath).catch((error) => { - if (error.code !== "ENOENT") throw error; - }); + return refIdentity; } - return await readRepositoryId(idPath, options); + // 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 ensureWorkspaceLockStore(gitCommonDir, options = {}) { @@ -978,10 +1084,10 @@ export async function runGitCommand(args, { return result; } -// A lease owner counts as concurrently active while its heartbeat is fresh -// and its worker has not finished; linked worktrees share one ref store, so -// another worktree's delegation is visible here and can interleave commits. -const CONCURRENT_LEASE_STALE_MS = 30_000; +// A starting/running lease ref is conservatively active until its exact owner +// CAS removes or transitions it. Periodic ownership probes intentionally avoid +// writing heartbeat blobs, and a stale timestamp cannot prove that an escaped +// worker (or a worker on another host sharing the repository) has stopped. async function gitSnapshot(worktreeRoot, options = {}) { const ownLockRef = typeof options.ownLockRef === "string" ? options.ownLockRef : null; @@ -1127,9 +1233,7 @@ async function gitSnapshot(worktreeRoot, options = {}) { continue; } const active = record && - (record.workerState === "starting" || record.workerState === "running") && - Number.isFinite(record.heartbeatAt) && - Date.now() - record.heartbeatAt < CONCURRENT_LEASE_STALE_MS; + (record.workerState === "starting" || record.workerState === "running"); if (active) concurrentDelegations += 1; } catch { /* malformed owner blob: ignore for disclosure */ } } diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs index 03e3f71..7d5e040 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs @@ -77,6 +77,100 @@ test("Linux ancestry refresh follows task children without scanning all of procf 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 })); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index b4ba18b..24a7acc 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url"; import { localHostIdentity, WORKSPACE_LOCK_REF_PREFIX, workspaceLockRef, } from "../workspace-lock.mjs"; +import { resolvePathCommand } from "../git-executable.mjs"; import { closestExistingBase, populateCommitishCache, runCommand, runGitCommand, } from "../server.mjs"; @@ -23,6 +24,24 @@ 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)); +}); + function unconfirmedGitResult(overrides = {}) { return { stdout: "", stderr: "", exitCode: null, timedOut: false, killed: true, @@ -147,6 +166,40 @@ test("post-exit inspection waits for an in-flight ancestry refresh", async () => 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) => { @@ -658,11 +711,21 @@ 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) { if (process.platform === "linux") { - const repositoryId = (await readFile(path.join( - canonicalGitCommonDir, "cli-agent-bridge-lock-store.git", "cli-agent-bridge-repository-id", - ), "utf8")).trim(); + const repositoryId = await repositoryIdFromStore(path.join( + canonicalGitCommonDir, "cli-agent-bridge-lock-store.git", + )); return "git-common-dir-id:" + repositoryId; } return "git-common-dir:" + path.normalize(canonicalGitCommonDir); @@ -958,13 +1021,62 @@ test("the private lock store inherits the repository sharing mode", async (conte 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 identityMode = (await stat( - path.join(lockStore, "cli-agent-bridge-repository-id"), - )).mode & 0o777; - assert.equal(identityMode & 0o060, 0o060, "the repository group must be able to read/write its identity"); + 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"); } }); +test("stale starting and running lease refs remain 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, 0, + "a stale idle owner is not an active delegation"); +}); + test("concurrent first requests publish one valid repository identity", async (context) => { const { workspace, configPath, client } = await makeHarness(context); const secondClient = new McpClient(configPath); @@ -981,10 +1093,13 @@ test("concurrent first requests publish one valid repository identity", async (c assert.equal(second.result.structuredContent.ok, true, JSON.stringify(second)); const commonDir = await canonicalGitCommonDirectory(workspace); const store = await coordinationLockStore(workspace); - const repositoryId = (await readFile( - path.join(store, "cli-agent-bridge-repository-id"), "utf8", - )).trim(); + 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-")), [], @@ -995,6 +1110,48 @@ test("concurrent first requests publish one valid repository identity", async (c } }); +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) => { @@ -1004,9 +1161,9 @@ test("a repository recreated at the same path gets a new logical lock identity", }); assert.equal(initial.result.structuredContent.ok, true, JSON.stringify(initial)); const firstCommonDir = await canonicalGitCommonDirectory(workspace); - const firstId = (await readFile(path.join( - firstCommonDir, "cli-agent-bridge-lock-store.git", "cli-agent-bridge-repository-id", - ), "utf8")).trim(); + 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" })); @@ -1020,9 +1177,9 @@ test("a repository recreated at the same path gets a new logical lock identity", }); assert.equal(recreated.result.structuredContent.ok, true, JSON.stringify(recreated)); const secondCommonDir = await canonicalGitCommonDirectory(workspace); - const secondId = (await readFile(path.join( - secondCommonDir, "cli-agent-bridge-lock-store.git", "cli-agent-bridge-repository-id", - ), "utf8")).trim(); + const secondId = await repositoryIdFromStore(path.join( + secondCommonDir, "cli-agent-bridge-lock-store.git", + )); assert.notEqual(secondId, firstId); }); From 68bbb1457ccad322455fc4cb15664af9e959ed91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Mon, 17 Aug 2026 07:42:40 +0800 Subject: [PATCH 31/40] test(cli-agent-bridge): isolate commit-base attribution fixture --- .../Hylouis233/cli-agent-bridge/server.mjs | 2 +- .../cli-agent-bridge/tests/server.test.mjs | 49 ++++++++++++++----- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 4e4714e..4295712 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -1332,7 +1332,7 @@ export async function closestExistingBase(worktreeRoot, target, baselineCommits, return null; } -async function committedDelta(worktreeRoot, before, after, options = {}) { +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] ?? ""; diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index 24a7acc..e1daf00 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -14,7 +14,7 @@ import { } from "../workspace-lock.mjs"; import { resolvePathCommand } from "../git-executable.mjs"; import { - closestExistingBase, populateCommitishCache, runCommand, runGitCommand, + closestExistingBase, committedDelta, populateCommitishCache, runCommand, runGitCommand, } from "../server.mjs"; const execFileAsync = promisify(execFile); @@ -2224,7 +2224,11 @@ test("target refs resembling coordination refs remain visible to attribution", a }); test("a ref moved from a blob to a new commit uses a commit-safe diff base", async (context) => { - const { tempRoot, workspace, client } = await makeHarness(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"); @@ -2232,16 +2236,37 @@ test("a ref moved from a blob to a new commit uses a commit-safe diff base", asy "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: "blob-to-commit", moveBlobRefToCommit: true, refName, - writeFile: "ref-commit.txt", commitMessage: "commit behind moved ref", - })); - assert.equal(response.result.error, undefined, "post-run attribution must not become a JSON-RPC error"); - const out = response.result.structuredContent; - assert.equal(out.ok, true, JSON.stringify(out.error)); - assert.match(out.commits.log, /commit behind moved ref/u); - assert.match(out.commits.diffStat, /ref-commit\.txt/u); - assert.doesNotMatch(out.commits.diffStat, new RegExp(blobOid.trim(), "u")); + 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 force-moved ref diffs from an ancestral pre-run baseline", async (context) => { From 2bbce4990fb18e3b94d5a288f588fcdc88b116f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Mon, 17 Aug 2026 15:44:44 +0800 Subject: [PATCH 32/40] fix(cli-agent-bridge): close latest safety gaps --- plugins/Hylouis233/cli-agent-bridge/README.md | 18 +- .../cli-agent-bridge/git-executable.mjs | 58 +-- .../Hylouis233/cli-agent-bridge/server.mjs | 406 ++++++++++++------ .../cli-agent-bridge/tests/fake-backend.mjs | 21 + .../cli-agent-bridge/tests/server.test.mjs | 309 +++++++++++-- .../tests/workspace-lock.test.mjs | 23 + .../cli-agent-bridge/workspace-lock.mjs | 12 + 7 files changed, 636 insertions(+), 211 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 6264706..21defaf 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -170,7 +170,8 @@ you already obtained a valid ID from that backend outside this Plugin. 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, moves its lease into the recoverable `quarantined` state, and every bridge + 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 @@ -183,7 +184,9 @@ you already obtained a valid ID from that backend outside this Plugin. 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 in a current-user-scoped OS temporary directory. + quarantine marker itself lives in a current-user-scoped OS temporary directory. New markers are + atomically claimed directories (legacy marker files remain readable), so publication does not + depend on hard-link support and the reported path is renamed the same way during recovery. On Linux, zombie-only tracked trees count as terminated; zombies cannot edit the workspace and may otherwise persist when container PID 1 does not reap them. - Cancelling a workspace_status request interrupts its queued lock wait or Git snapshot and returns @@ -197,10 +200,13 @@ you already obtained a valid ID from that backend outside this Plugin. 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; - remote-tracking updates and fetched tag-only tips are treated as externally sourced history. - FETCH_HEAD tips supply the same external baseline when fetch writes through an arbitrary refspec - directly into a local branch or custom ref, including when a local worker commit builds on the - fetched tip. Commit-tip classification is batched and each changed target uses one boundary graph + 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 successful fetch/pull operations in + the canonical workspace; 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. diff --git a/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs b/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs index c78b3dc..e3d797c 100644 --- a/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs @@ -1,30 +1,10 @@ -import { createHash } from "node:crypto"; import { constants } from "node:fs"; -import { access, mkdir, realpath, stat } from "node:fs/promises"; -import os from "node:os"; +import { access, realpath, stat } from "node:fs/promises"; import path from "node:path"; let executablePromise = null; -let hooksRootPromise = null; const pathCommandPromises = new Map(); -function userScope() { - let identity; - try { - const user = os.userInfo(); - identity = Number.isInteger(user.uid) && user.uid >= 0 - ? process.platform + ":uid:" + String(user.uid) - : process.platform + ":" + user.username + ":" + user.homedir; - } catch { - identity = process.platform + ":" + (process.env.USERNAME ?? process.env.USER ?? os.homedir()); - } - return createHash("sha256").update(identity).digest("hex").slice(0, 20); -} - -const DISABLED_HOOKS_ROOT = path.join( - os.tmpdir(), "minimax-cli-agent-bridge-git-" + userScope(), "disabled-hooks", -); - async function resolveGitExecutable() { const names = process.platform === "win32" ? ["git.exe", "git.com"] : ["git"]; for (const rawDirectory of (process.env.PATH ?? "").split(path.delimiter)) { @@ -96,37 +76,29 @@ export function trustedGitExecutable() { return executablePromise; } -async function disabledHooksRoot() { - hooksRootPromise ??= mkdir(DISABLED_HOOKS_ROOT, { recursive: true, mode: 0o700 }) - .then(() => DISABLED_HOOKS_ROOT) - .catch((error) => { - hooksRootPromise = null; - throw error; - }); - return await hooksRootPromise; -} - -export async function safeGitInvocation(args) { +export async function safeGitInvocation(args, baseEnvironment = process.env) { const safeArgs = [ - "-c", "core.hooksPath=" + await disabledHooksRoot(), + // 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"); - const env = { - ...process.env, + // 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: "", - }; - for (const name of [ - "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG_COUNT", "GIT_DIR", "GIT_DIFF_OPTS", - "GIT_EXTERNAL_DIFF", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_WORK_TREE", - ]) delete env[name]; - for (const name of Object.keys(env)) { - if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/u.test(name)) delete env[name]; - } + }); return { command: await trustedGitExecutable(), args: safeArgs, env }; } diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 4295712..6a652e6 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -8,7 +8,7 @@ import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { chmod, link, mkdir, mkdtemp, open, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, open, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises"; import os from "node:os"; import { fileURLToPath } from "node:url"; import path from "node:path"; @@ -38,6 +38,8 @@ const KILL_GRACE_MS = Number.isInteger(TEST_KILL_GRACE_MS) && TEST_KILL_GRACE_MS : 10_000; const MAX_CAPTURE_CHARS = 5_000_000; const RAW_TAIL_CHARS = 60_000; +const FETCH_PROVENANCE_TIPS = Symbol("fetchProvenanceTips"); +const QUARANTINE_RECORD_FILE = "record.json"; const TEST_RUNTIME_PLATFORM = process.env.NODE_ENV === "test" ? process.env.CLI_AGENT_BRIDGE_TEST_PLATFORM : ""; @@ -1040,6 +1042,117 @@ class GitProcessTreeUnconfirmedError extends Error { } } +const BACKEND_GIT_ROUTING_VARIABLES = new Set([ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_CONFIG", + "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS", "GIT_DIR", "GIT_GRAFT_FILE", + "GIT_IMPLICIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_NAMESPACE", "GIT_OBJECT_DIRECTORY", + "GIT_PREFIX", "GIT_QUARANTINE_PATH", "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 successful fetch/pull commands in the canonical target + // repository. Git does not expose a complete, cross-version per-fetch tip + // log, so their presence makes commit attribution explicitly unavailable + // instead of relying on the last (overwritable) FETCH_HEAD contents. + env.GIT_TRACE2_EVENT = tracePath; + return env; +} + +async function createBackendGitProvenance(baseEnvironment = process.env) { + const root = await mkdtemp(path.join(os.tmpdir(), "minimax-cli-agent-fetch-")); + const tracePath = path.join(root, "git.trace"); + try { + await chmod(root, 0o700); + await writeFile(tracePath, "", { flag: "wx", mode: 0o600 }); + return { + root, tracePath, + env: backendGitProvenanceEnvironment(tracePath, baseEnvironment), + }; + } catch (error) { + await rm(root, { recursive: true, force: true }).catch(() => {}); + throw error; + } +} + +export async function readBackendGitProvenance(provenance, worktreeRoot) { + const traceStat = await stat(provenance.tracePath); + if (traceStat.size > MAX_CAPTURE_CHARS) { + throw new Error("Git fetch provenance trace exceeded the capture limit"); + } + const trace = await readFile(provenance.tracePath, "utf8"); + const sessions = new Map(); + const normalizeWorktree = (value) => { + const normalized = path.resolve(String(value ?? "")); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; + }; + const targetWorktree = normalizeWorktree(worktreeRoot); + for (const line of trace.split(/\r?\n/u)) { + if (!line.startsWith("{")) continue; + 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; + } + } + } + let uncertain = false; + let sawFetch = false; + for (const session of sessions.values()) { + if (session.worktree === null || !session.exited) { + uncertain = true; + continue; + } + if (session.worktree === targetWorktree && session.exitCode === 0) { + sawFetch = true; + uncertain = true; + } + } + return { uncertain, sawFetch }; +} + export async function runGitCommand(args, { cwd, cancel = null, @@ -1164,9 +1277,12 @@ async function gitSnapshot(worktreeRoot, options = {}) { ...nulNames(out.cachedDiffNames), ...nulNames(out.untracked), ].filter((f) => (seen.has(f) ? false : (seen.add(f), true))); - const diffStat = [String(out.diffStat ?? "").trim(), String(out.cachedDiffStat ?? "").trim()] - .filter(Boolean) - .map((s, i) => (i === 0 ? s : s.split(/\r?\n/).map((l) => "staged: " + l).join("\n"))) + 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)) { @@ -1237,7 +1353,7 @@ async function gitSnapshot(worktreeRoot, options = {}) { if (active) concurrentDelegations += 1; } catch { /* malformed owner blob: ignore for disclosure */ } } - return { + 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, ""), @@ -1249,6 +1365,7 @@ async function gitSnapshot(worktreeRoot, options = {}) { fetchHeads, concurrentDelegations, }; + return snapshot; } // Peel an object id to a commit id. Returns null for blob/tree objects (legal @@ -1339,6 +1456,17 @@ export async function committedDelta(worktreeRoot, before, after, options = {}) 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 = ""; @@ -1365,7 +1493,6 @@ export async function committedDelta(worktreeRoot, before, after, options = {}) ...(before.fetchHeads ?? []), after.head, ...Object.values(after.refs ?? {}), - ...(after.fetchHeads ?? []), ...refsChanged.flatMap((change) => [change.before, change.after]), ], cache, options); // Baseline: every commit that already existed before the worker ran. New @@ -1385,38 +1512,6 @@ export async function committedDelta(worktreeRoot, before, after, options = {}) ])) { await addBaseline(oid); } - // Commits introduced by fetch live under remote-tracking refs and were - // created outside this worker. Add their after-state tips to the exclusion - // baseline before attributing any local branch/tag that builds on them. - const movedLocalTargets = new Set([ - before.head === after.head ? "" : after.head, - ...refsChanged - .filter((change) => change.ref.startsWith("refs/heads/")) - .map((change) => change.after), - ].filter(Boolean)); - const externalRefChanges = []; - for (const change of refsChanged) { - if (change.ref.startsWith("refs/remotes/") || change.ref.startsWith("refs/prefetch/")) { - externalRefChanges.push(change); - continue; - } - if (!change.ref.startsWith("refs/tags/") || change.before || !change.after) continue; - const tagCommit = await peelCommitish(worktreeRoot, change.after, cache, options); - // A newly arriving tag without a moved local HEAD/branch at the same commit - // is conservatively treated as fetch-sourced. Existing tags may be moved by - // the worker (including from a non-commit object) and remain attribution - // labels because a before/after snapshot cannot prove such a move was fetch. - if (tagCommit && !movedLocalTargets.has(tagCommit)) externalRefChanges.push(change); - } - for (const change of externalRefChanges) await addBaseline(change.after); - // FETCH_HEAD records the actual objects downloaded by fetch independently - // of its arbitrary destination refspec. A fetch can write directly into - // refs/heads or any custom namespace, so namespace alone cannot establish - // provenance. Exclude every recorded fetched tip while retaining the local - // destination movement as an attribution target for any later worker commit. - for (const oid of after.fetchHeads ?? []) await addBaseline(oid); - const externalRefNames = new Set(externalRefChanges.map((change) => change.ref)); - // 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. @@ -1434,22 +1529,10 @@ export async function committedDelta(worktreeRoot, before, after, options = {}) } addTarget("HEAD", before.head, after.head); for (const change of refsChanged) { - if (externalRefNames.has(change.ref)) continue; addTarget(change.ref, change.before, change.after); } - const movementLogs = externalRefChanges.map((change) => - change.ref + " moved to externally sourced history; excluded from worker-created commits", - ); - const beforeFetchHeads = new Set(before.fetchHeads ?? []); - for (const oid of after.fetchHeads ?? []) { - if (!beforeFetchHeads.has(oid)) { - movementLogs.push( - "FETCH_HEAD recorded externally fetched history at " + oid.slice(0, 12) + - "; excluded from worker-created commits", - ); - } - } + const movementLogs = []; if ((before.headRef ?? "") !== (after.headRef ?? "")) { movementLogs.push( "HEAD symbolic target " + (before.headRef || "(detached)") + @@ -1635,9 +1718,22 @@ function workspaceQuarantineRecoveryPath(key) { async function readWorkspaceQuarantine(key) { const quarantinePath = workspaceQuarantinePath(key); try { - const raw = await readFile(quarantinePath, "utf8"); + const marker = await stat(quarantinePath); + let raw = null; + if (marker.isDirectory()) { + try { + raw = await 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 readFile(quarantinePath, "utf8"); + } let details; - try { details = JSON.parse(raw); } catch { details = { error: "invalid quarantine record" }; } + try { + details = typeof raw === "string" ? JSON.parse(raw) : { error: "invalid quarantine record" }; + } catch { details = { error: "invalid quarantine record" }; } return { quarantinePath, details }; } catch (error) { if (error.code === "ENOENT") return null; @@ -1651,7 +1747,11 @@ async function readWorkspaceQuarantine(key) { // over to a later incident. async function quarantineRecoveryApproved(key, owner) { try { - const raw = await readFile(workspaceQuarantineRecoveryPath(key), "utf8"); + const recoveryPath = workspaceQuarantineRecoveryPath(key); + const marker = await stat(recoveryPath); + const raw = marker.isDirectory() + ? await readFile(path.join(recoveryPath, QUARANTINE_RECORD_FILE), "utf8") + : await readFile(recoveryPath, "utf8"); const record = JSON.parse(raw); return typeof owner?.quarantineId === "string" && record?.quarantineId === owner.quarantineId; @@ -1661,15 +1761,15 @@ async function quarantineRecoveryApproved(key, owner) { } async function clearQuarantineRecoveryApproval(key) { - await unlink(workspaceQuarantineRecoveryPath(key)).catch((error) => { - if (error.code !== "ENOENT") throw error; - }); + await rm(workspaceQuarantineRecoveryPath(key), { recursive: true, force: true }); } -async function markWorkspaceQuarantined(key, details) { +export async function markWorkspaceQuarantined(key, details, quarantineId = randomUUID()) { + if (typeof quarantineId !== "string" || !quarantineId) { + throw new Error("quarantine id is unavailable"); + } await mkdir(WORKSPACE_LOCK_ROOT, { recursive: true, mode: 0o700 }); const quarantinePath = workspaceQuarantinePath(key); - const quarantineId = randomUUID(); const token = process.pid + "-" + randomUUID(); const temporaryPath = quarantinePath + ".owner-" + token; const record = { @@ -1679,18 +1779,38 @@ async function markWorkspaceQuarantined(key, details) { processIdentity: await cachedProcessStartIdentity(process.pid), quarantinedAt: new Date().toISOString(), }; - await writeFile(temporaryPath, JSON.stringify(record), { flag: "wx", mode: 0o600 }); + await mkdir(temporaryPath, { mode: 0o700 }); + await writeFile(path.join(temporaryPath, QUARANTINE_RECORD_FILE), JSON.stringify(record), { + flag: "wx", mode: 0o600, + }); + let preserveTemporary = false; + let published = false; try { try { - await link(temporaryPath, quarantinePath); + await stat(quarantinePath); + throw new Error("workspace quarantine marker already exists at " + quarantinePath); } catch (error) { - if (error.code === "EEXIST") { + 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 { - try { await unlink(temporaryPath); } catch (error) { if (error.code !== "ENOENT") throw error; } + if (!preserveTemporary) await rm(temporaryPath, { recursive: true, force: true }); } return { quarantinePath, quarantineId, details: record }; } @@ -1764,13 +1884,13 @@ async function quarantineLeaseForProcessTree({ }) { 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(lockKey, details); - try { - await workspaceLease.markWorkerQuarantined(quarantine.quarantineId); - } catch { /* the retained lease remains fail-closed; the marker explains recovery */ } + const quarantine = await markWorkspaceQuarantined(lockKey, details, quarantineId); + await workspaceLease.markWorkerQuarantined(quarantine.quarantineId); quarantinedWorkspaces.delete(lockKey); return { quarantinePath: quarantine.quarantinePath, details: quarantine.details }; } @@ -2237,74 +2357,99 @@ async function delegateTask(rawArgs, cancel) { ownershipLostError ??= error; }); let workerLockUpdate = Promise.resolve(); - const result = await runCommand(backendCommand, args, { - cwd: workspacePath, - 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); - }); - }, - }); - // Keep the controller live while runCommand is still inspecting or - // terminating escaped descendants after the leader closes. Once the full - // command result settles, clear it before any further await so a late lease - // notification cannot signal a reused PID/process-group identifier. - workerFinished = true; - if (cancel?.controller === workerController) cancel.controller = null; - workerController = null; - await workerLockUpdate; - let quarantinePath = ""; - if (!result.treeTerminated) { - quarantinedWorkspaces.add(lockKey); - workspaceLease.retain(); - // Persist the operator-visible marker before making the retained lease - // recoverable. If persistence fails, the lease remains in the - // unreclaimable running state instead of mistaking temporary-file cleanup - // for an explicit operator recovery authorization. - const quarantine = await markWorkspaceQuarantined(lockKey, { - backend, - workspacePath, - worktreeRoot, - lockRef: workspaceLease.ref, - terminationError: result.terminationError, + const gitProvenance = await createBackendGitProvenance(); + let fetchProvenanceTips = { uncertain: false, sawFetch: false }; + let result; + 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); + }); + }, }); - quarantinePath = quarantine.quarantinePath; - try { + } 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; + } + 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(lockKey, { + backend, + workspacePath, + worktreeRoot, + lockRef: workspaceLease.ref, + terminationError: result.terminationError, + }, quarantineId); + quarantinePath = quarantine.quarantinePath; await workspaceLease.markWorkerQuarantined(quarantine.quarantineId); - } catch { /* running state remains fail-closed; the marker still explains manual recovery */ } - // 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); + // 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; + if (ownershipLostError) { + await ownershipTermination; + if (ownershipTerminationError && ownershipLostError.cause === undefined) { + ownershipLostError.cause = ownershipTerminationError; + } + throw ownershipLostError; } - throw ownershipLostError; + if (result.treeTerminated) { + try { + fetchProvenanceTips = await readBackendGitProvenance(gitProvenance, worktreeRoot); + } 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 rm(gitProvenance.root, { recursive: true, force: true }).catch(() => {}); } let after = null; let commits = null; @@ -2319,6 +2464,7 @@ async function delegateTask(rawArgs, cancel) { concurrencyWindowStart: attributionWindowStart, onUnconfirmedProcessTree: quarantineGitProcessTree, }); + Object.defineProperty(after, FETCH_PROVENANCE_TIPS, { value: fetchProvenanceTips }); commits = await committedDelta(worktreeRoot, before, after, { cancel, deadline }); } catch (error) { if (error instanceof GitProcessTreeUnconfirmedError) { diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs index 91a0033..6c952af 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs @@ -86,6 +86,27 @@ if (spec.branchRoundTrip) { 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.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.mirrorPush) { event("start"); execFileSync("git", ["push", "--mirror", spec.remotePath]); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index e1daf00..f48ba35 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -12,9 +12,10 @@ import { fileURLToPath } from "node:url"; import { localHostIdentity, WORKSPACE_LOCK_REF_PREFIX, workspaceLockRef, } from "../workspace-lock.mjs"; -import { resolvePathCommand } from "../git-executable.mjs"; +import { resolvePathCommand, safeGitInvocation } from "../git-executable.mjs"; import { - closestExistingBase, committedDelta, populateCommitishCache, runCommand, runGitCommand, + backendGitProvenanceEnvironment, closestExistingBase, committedDelta, markWorkspaceQuarantined, + populateCommitishCache, readBackendGitProvenance, runCommand, runGitCommand, } from "../server.mjs"; const execFileAsync = promisify(execFile); @@ -42,6 +43,69 @@ test("failed backend command resolutions are retried after installation", async assert.equal(await resolvePathCommand(command), 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")), + ); +}); + function unconfirmedGitResult(overrides = {}) { return { stdout: "", stderr: "", exitCode: null, timedOut: false, killed: true, @@ -1006,6 +1070,23 @@ test("porcelain status preserves the unstaged first-column space", async (contex 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 }); @@ -1544,7 +1625,7 @@ test("unconfirmed termination after lease loss quarantines delegation and status await client.initialize(); const canonicalRoot = await canonicalGitCommonDirectory(workspace); ({ quarantinePath } = await repositoryStatePaths(canonicalRoot)); - context.after(() => rm(quarantinePath, { force: true })); + context.after(() => rm(quarantinePath, { recursive: true, force: true })); const key = await repositoryKey(canonicalRoot); ref = workspaceLockRef(key); lockStore = await coordinationLockStore(workspace); @@ -1593,7 +1674,7 @@ test("unconfirmed termination after lease loss quarantines delegation and status await execFileAsync(realTaskkill, ["/PID", String(workerPid), "/T", "/F"]); workerPid = null; - await rm(quarantinePath, { force: true }); + 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, { @@ -1624,7 +1705,7 @@ test("a quarantine marker blocks delegations in every server process", async (co await canonicalGitCommonDirectory(workspace), ); await mkdir(root, { recursive: true }); - context.after(() => rm(quarantinePath, { force: 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", @@ -1638,6 +1719,24 @@ test("a quarantine marker blocks delegations in every server process", async (co } }); +test("quarantine publication uses an exclusive directory claim without hard links", async (context) => { + const key = "quarantine-directory-publication:" + String(process.pid) + ":" + String(Date.now()); + const quarantine = await markWorkspaceQuarantined(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(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", { @@ -1672,8 +1771,8 @@ test("quarantine recovery requires an explicit incident-bound approval", async ( const { root, quarantinePath, recoveryPath } = await repositoryStatePaths(commonDir); await mkdir(root, { recursive: true }); - context.after(() => rm(quarantinePath, { force: true })); - context.after(() => rm(recoveryPath, { force: 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 @@ -1689,7 +1788,8 @@ test("quarantine recovery requires an explicit incident-bound approval", async ( // Renaming the incident record is the documented explicit approval. The CAS // winner consumes it only after acquiring the exact quarantined lease. - await writeFile(quarantinePath, record); + 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, @@ -2462,7 +2562,7 @@ test("new-branch attribution considers baselines beyond the first 256 tips", { assert.match(out.commits.diffStat, /late-worker\.txt/u); }); -test("fetched remote history is excluded from worker-created commits", async (context) => { +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, @@ -2471,17 +2571,17 @@ test("fetched remote history is excluded from worker-created commits", async (co })); const out = response.result.structuredContent; assert.equal(out.ok, true, JSON.stringify(out.error)); - assert.equal(out.commits.newCommitCount, 1, out.commits.log); + assert.equal(out.commits.newCommitCount, 2, out.commits.log); assert.match(out.commits.log, /worker commit after fetch/u); - assert.doesNotMatch(out.commits.log, /fetched upstream commit/u); - assert.match(out.commits.log, /refs\/remotes\/origin\/main moved to externally sourced history/u); - assert.doesNotMatch(out.commits.diffStat, /upstream\.txt/u, - "external fetched content is part of the attribution baseline"); + 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("fetch history directed into a local ref remains an external baseline", async (context) => { +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 }); @@ -2492,24 +2592,171 @@ test("fetch history directed into a local ref remains an external baseline", asy 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(workspace, { + 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.newCommitCount, 1, out.commits.log); - assert.match(out.commits.log, /worker commit after local-ref fetch/u); - assert.doesNotMatch(out.commits.log, /externally fetched local-ref commit/u); - assert.match(out.commits.log, /FETCH_HEAD recorded externally fetched history/u); + 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)); - assert.doesNotMatch(out.commits.diffStat, /external-local-ref\.txt/u); - assert.match(out.commits.diffStat, /worker-after-local-fetch\.txt/u); }); -test("a fetched tag tip is an external baseline for later worker commits", async (context) => { +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"); + await writeFile(tracePath, ""); + 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 }, 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("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, @@ -2518,15 +2765,14 @@ test("a fetched tag tip is an external baseline for later worker commits", async })); const out = response.result.structuredContent; assert.equal(out.ok, true, JSON.stringify(out.error)); - assert.equal(out.commits.newCommitCount, 1, out.commits.log); + assert.equal(out.commits.newCommitCount, 2, out.commits.log); assert.match(out.commits.log, /worker commit after fetched tag/u); - assert.doesNotMatch(out.commits.log, /fetched upstream commit/u); - assert.match(out.commits.log, /refs\/tags\/fetched-tag moved to externally sourced history/u); - assert.doesNotMatch(out.commits.diffStat, /upstream\.txt/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("prefetch refs are external baselines for later worker commits", async (context) => { +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, @@ -2535,11 +2781,10 @@ test("prefetch refs are external baselines for later worker commits", async (con })); const out = response.result.structuredContent; assert.equal(out.ok, true, JSON.stringify(out.error)); - assert.equal(out.commits.newCommitCount, 1, out.commits.log); + assert.equal(out.commits.newCommitCount, 2, out.commits.log); assert.match(out.commits.log, /worker commit after prefetch/u); - assert.doesNotMatch(out.commits.log, /fetched upstream commit/u); - assert.match(out.commits.log, /refs\/prefetch\/remotes\/origin\/main moved to externally sourced history/u); - assert.doesNotMatch(out.commits.diffStat, /upstream\.txt/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); }); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs index b122c7d..16100c0 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs @@ -448,6 +448,29 @@ test("a quarantined lease without proof of a durable marker fails closed", async "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; diff --git a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs index 4e083fe..afb4bec 100644 --- a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs @@ -303,6 +303,10 @@ async function canReclaim(owner, { } 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; @@ -436,6 +440,14 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { 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"); From 7ac1c50f4062288016bcb9fec93b68a65b5f704c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Mon, 17 Aug 2026 16:29:26 +0800 Subject: [PATCH 33/40] fix(cli-agent-bridge): harden delegation state handling --- plugins/Hylouis233/cli-agent-bridge/README.md | 9 +- .../cli-agent-bridge/git-executable.mjs | 76 +++-- .../Hylouis233/cli-agent-bridge/server.mjs | 248 ++++++++++++---- .../cli-agent-bridge/tests/fake-backend.mjs | 11 + .../cli-agent-bridge/tests/server.test.mjs | 265 ++++++++++++++++-- 5 files changed, 512 insertions(+), 97 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 21defaf..147276c 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -184,9 +184,12 @@ you already obtained a valid ID from that backend outside this Plugin. 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 in a current-user-scoped OS temporary directory. New markers are - atomically claimed directories (legacy marker files remain readable), so publication does not - depend on hard-link support and the reported path is renamed the same way during recovery. + 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. On Linux, zombie-only tracked trees count as terminated; zombies cannot edit the workspace and may otherwise persist when container PID 1 does not reap them. - Cancelling a workspace_status request interrupts its queued lock wait or Git snapshot and returns diff --git a/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs b/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs index e3d797c..9ffc9c2 100644 --- a/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs @@ -1,9 +1,9 @@ import { constants } from "node:fs"; -import { access, realpath, stat } from "node:fs/promises"; +import { access, appendFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; let executablePromise = null; -const pathCommandPromises = new Map(); +const pathCommandEntries = new Map(); async function resolveGitExecutable() { const names = process.platform === "win32" ? ["git.exe", "git.com"] : ["git"]; @@ -25,6 +25,16 @@ async function resolveGitExecutable() { } 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); @@ -51,24 +61,56 @@ async function resolvePathCommandUncached(command) { return null; } -export function resolvePathCommand(command) { - if (typeof command !== "string" || !command) return Promise.resolve(null); - if (!pathCommandPromises.has(command)) { - const resolution = resolvePathCommandUncached(command); - pathCommandPromises.set(command, resolution); - // Share an in-flight lookup and retain positive results, but do not make a - // missing/not-yet-installed CLI permanent for the lifetime of the server. - // The identity guard prevents an older completion from deleting a newer - // retry that has already occupied the same cache slot. - void resolution.then((resolved) => { - if (resolved === null && pathCommandPromises.get(command) === resolution) { - pathCommandPromises.delete(command); +function pathCommandEntry(command) { + if (typeof command !== "string" || !command) { + return { promise: Promise.resolve(null), settled: true, value: null, error: null }; + } + if (!pathCommandEntries.has(command)) { + const entry = { + promise: resolvePathCommandUncached(command), + settled: false, + 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.settled = true; + entry.value = resolved; + for (const waiter of entry.waiters) waiter.resolve(resolved); + entry.waiters.clear(); + // Retain positive results, but retry a missing/not-yet-installed CLI. + if (resolved === null && pathCommandEntries.get(command) === entry) { + pathCommandEntries.delete(command); } - }, () => { - if (pathCommandPromises.get(command) === resolution) pathCommandPromises.delete(command); + }, (error) => { + entry.settled = true; + entry.error = error; + for (const waiter of entry.waiters) waiter.reject(error); + entry.waiters.clear(); + if (pathCommandEntries.get(command) === entry) pathCommandEntries.delete(command); }); } - return pathCommandPromises.get(command); + return pathCommandEntries.get(command); +} + +export function resolvePathCommand(command) { + return pathCommandEntry(command).promise; +} + +export function subscribePathCommand(command, resolve, reject) { + const entry = pathCommandEntry(command); + if (entry.settled) { + queueMicrotask(() => entry.error ? reject(entry.error) : resolve(entry.value)); + return () => {}; + } + const waiter = { resolve, reject }; + entry.waiters.add(waiter); + return () => { entry.waiters.delete(waiter); }; } export function trustedGitExecutable() { diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 6a652e6..dc44d3c 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -8,12 +8,12 @@ import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { chmod, mkdir, mkdtemp, open, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises"; +import { chmod, lstat, mkdir, mkdtemp, open, readFile, realpath, rename, rm, 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 } from "./git-executable.mjs"; +import { resolvePathCommand, safeGitInvocation, subscribePathCommand } from "./git-executable.mjs"; import { initializeProcessTree, isProcessTreeAlive, refreshProcessTree, signalProcessTree, waitForChildExit, waitForProcessTreeExit } from "./process-tree.mjs"; import { acquireGitWorkspaceLock, @@ -40,6 +40,7 @@ const MAX_CAPTURE_CHARS = 5_000_000; const RAW_TAIL_CHARS = 60_000; const FETCH_PROVENANCE_TIPS = Symbol("fetchProvenanceTips"); 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 : ""; @@ -61,23 +62,6 @@ function trustedWindowsPowerShell() { ); } -function currentUserLockScope() { - let identity; - try { - const user = os.userInfo(); - identity = Number.isInteger(user.uid) && user.uid >= 0 - ? process.platform + ":uid:" + String(user.uid) - : process.platform + ":" + user.username + ":" + user.homedir; - } catch { - identity = process.platform + ":" + (process.env.USERNAME ?? process.env.USER ?? os.homedir()); - } - return createHash("sha256").update(identity).digest("hex").slice(0, 20); -} - -const WORKSPACE_LOCK_ROOT = path.join( - os.tmpdir(), "minimax-cli-agent-bridge-locks-" + currentUserLockScope(), -); - // Built-in defaults. The sibling backends.json (or the CLI_AGENT_BRIDGE_BACKENDS // environment variable) overrides these; a missing or invalid file falls back // to this table. @@ -674,6 +658,43 @@ function interruptibleFilesystemOperation(operation, { cancel = null, deadline = }); } +function resolveBackendCommand(command, 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 = subscribePathCommand( + command, + (value) => finish(resolve, value), + (error) => finish(reject, error), + ); + }); +} + async function validateWorkspace(workspacePath, options = {}) { if (typeof workspacePath !== "string" || !workspacePath.trim()) { throw new Error("workspacePath must be a non-empty string"); @@ -964,6 +985,55 @@ async function ensureRepositoryId(storeRoot, 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; @@ -1015,7 +1085,12 @@ async function ensureWorkspaceLockStore(gitCommonDir, options = {}) { // 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. - return { root: storeRoot, repositoryId: await ensureRepositoryId(storeRoot, options) }; + const repositoryId = await ensureRepositoryId(storeRoot, options); + return { + root: storeRoot, + repositoryId, + quarantineRoot: await ensureWorkspaceQuarantineRoot(storeRoot, options), + }; } function snapshotFailure(label, result) { @@ -1044,10 +1119,12 @@ class GitProcessTreeUnconfirmedError extends Error { const BACKEND_GIT_ROUTING_VARIABLES = new Set([ "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_CONFIG", - "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS", "GIT_DIR", "GIT_GRAFT_FILE", - "GIT_IMPLICIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_NAMESPACE", "GIT_OBJECT_DIRECTORY", - "GIT_PREFIX", "GIT_QUARANTINE_PATH", "GIT_REPLACE_REF_BASE", "GIT_SHALLOW_FILE", - "GIT_WORK_TREE", + "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) { @@ -1667,7 +1744,13 @@ async function listBackends(cancel = null) { }); continue; } - const resolvedCommand = await resolvePathCommand(spec.command); + let resolvedCommand; + try { + resolvedCommand = await resolveBackendCommand(spec.command, { cancel }); + } catch (error) { + if (error instanceof OperationCancelledError) break; + throw error; + } if (!resolvedCommand) { entries.push({ name, @@ -1706,17 +1789,17 @@ async function listBackends(cancel = null) { return entries; } -function workspaceQuarantinePath(key) { +function workspaceQuarantinePath(quarantineRoot, key) { const digest = createHash("sha256").update(key).digest("hex"); - return path.join(WORKSPACE_LOCK_ROOT, digest + ".quarantine"); + return path.join(quarantineRoot, digest + ".quarantine"); } -function workspaceQuarantineRecoveryPath(key) { - return workspaceQuarantinePath(key) + ".recovery-approved"; +function workspaceQuarantineRecoveryPath(quarantineRoot, key) { + return workspaceQuarantinePath(quarantineRoot, key) + ".recovery-approved"; } -async function readWorkspaceQuarantine(key) { - const quarantinePath = workspaceQuarantinePath(key); +async function readWorkspaceQuarantine(quarantineRoot, key) { + const quarantinePath = workspaceQuarantinePath(quarantineRoot, key); try { const marker = await stat(quarantinePath); let raw = null; @@ -1734,7 +1817,7 @@ async function readWorkspaceQuarantine(key) { try { details = typeof raw === "string" ? JSON.parse(raw) : { error: "invalid quarantine record" }; } catch { details = { error: "invalid quarantine record" }; } - return { quarantinePath, details }; + return { quarantinePath: await realpath(quarantinePath), details }; } catch (error) { if (error.code === "ENOENT") return null; throw error; @@ -1745,9 +1828,9 @@ async function readWorkspaceQuarantine(key) { // 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(key, owner) { +async function quarantineRecoveryApproved(quarantineRoot, key, owner) { try { - const recoveryPath = workspaceQuarantineRecoveryPath(key); + const recoveryPath = workspaceQuarantineRecoveryPath(quarantineRoot, key); const marker = await stat(recoveryPath); const raw = marker.isDirectory() ? await readFile(path.join(recoveryPath, QUARANTINE_RECORD_FILE), "utf8") @@ -1760,16 +1843,27 @@ async function quarantineRecoveryApproved(key, owner) { } } -async function clearQuarantineRecoveryApproval(key) { - await rm(workspaceQuarantineRecoveryPath(key), { recursive: true, force: true }); +async function clearQuarantineRecoveryApproval(quarantineRoot, key) { + await rm(workspaceQuarantineRecoveryPath(quarantineRoot, key), { recursive: true, force: true }); } -export async function markWorkspaceQuarantined(key, details, quarantineId = randomUUID()) { +export async function markWorkspaceQuarantined( + quarantineRoot, key, details, quarantineId = randomUUID(), +) { if (typeof quarantineId !== "string" || !quarantineId) { throw new Error("quarantine id is unavailable"); } - await mkdir(WORKSPACE_LOCK_ROOT, { recursive: true, mode: 0o700 }); - const quarantinePath = workspaceQuarantinePath(key); + 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 = { @@ -1779,10 +1873,14 @@ export async function markWorkspaceQuarantined(key, details, quarantineId = rand processIdentity: await cachedProcessStartIdentity(process.pid), quarantinedAt: new Date().toISOString(), }; - await mkdir(temporaryPath, { mode: 0o700 }); + 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: 0o600, + flag: "wx", mode: markerFileMode, }); + if (process.platform !== "win32") { + await chmod(path.join(temporaryPath, QUARANTINE_RECORD_FILE), markerFileMode); + } let preserveTemporary = false; let published = false; try { @@ -1812,7 +1910,7 @@ export async function markWorkspaceQuarantined(key, details, quarantineId = rand } finally { if (!preserveTemporary) await rm(temporaryPath, { recursive: true, force: true }); } - return { quarantinePath, quarantineId, details: record }; + return { quarantinePath: await realpath(quarantinePath), quarantineId, details: record }; } let linuxBootIdPromise = null; @@ -1880,7 +1978,8 @@ async function serverProcessStartIdentity() { const workspaceLocks = new Map(); const quarantinedWorkspaces = new Set(); async function quarantineLeaseForProcessTree({ - lockKey, workspaceLease, backend, workspacePath, worktreeRoot, terminationError, + quarantineRoot, lockKey, workspaceLease, backend, workspacePath, worktreeRoot, + terminationError, }) { quarantinedWorkspaces.add(lockKey); workspaceLease.retain(); @@ -1889,7 +1988,9 @@ async function quarantineLeaseForProcessTree({ const details = { backend, workspacePath, worktreeRoot, lockRef: workspaceLease.ref, terminationError, }; - const quarantine = await markWorkspaceQuarantined(lockKey, details, quarantineId); + const quarantine = await markWorkspaceQuarantined( + quarantineRoot, lockKey, details, quarantineId, + ); await workspaceLease.markWorkerQuarantined(quarantine.quarantineId); quarantinedWorkspaces.delete(lockKey); return { quarantinePath: quarantine.quarantinePath, details: quarantine.details }; @@ -2100,6 +2201,10 @@ function quarantinedWorkspaceStatus(id, { workspacePath = "", worktreeRoot = "" } async function delegateTask(rawArgs, cancel) { + const timeoutMs = Number.isInteger(rawArgs?.timeoutMs) + ? Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, rawArgs.timeoutMs)) + : DEFAULT_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; const backends = await loadBackends(); if (!rawArgs || typeof rawArgs.backend !== "string" || !rawArgs.backend.trim()) { throw new Error("backend must be a non-empty string"); @@ -2133,7 +2238,24 @@ async function delegateTask(rawArgs, cancel) { experimental: Boolean(spec.experimental), }; } - const backendCommand = await resolvePathCommand(spec.command); + 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, @@ -2154,14 +2276,11 @@ async function delegateTask(rawArgs, cancel) { experimental: Boolean(spec.experimental), }; } - const timeoutMs = Number.isInteger(rawArgs.timeoutMs) - ? Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, rawArgs.timeoutMs)) - : DEFAULT_TIMEOUT_MS; - const deadline = Date.now() + timeoutMs; let workspacePath = ""; let worktreeRoot = ""; let gitCommonDir = ""; let lockStoreRoot = ""; + let quarantineRoot = ""; let repositoryAccess = null; try { if (cancel?.cancelled) { @@ -2175,6 +2294,7 @@ async function delegateTask(rawArgs, cancel) { 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(() => {}); @@ -2194,13 +2314,13 @@ async function delegateTask(rawArgs, cancel) { } const lockKey = repositoryAccess.key; try { - const existingQuarantine = await readWorkspaceQuarantine(lockKey); + const existingQuarantine = await readWorkspaceQuarantine(quarantineRoot, lockKey); 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(lockKey); + const sharedQuarantine = await readWorkspaceQuarantine(quarantineRoot, lockKey); if (quarantinedWorkspaces.has(lockKey) || sharedQuarantine) { return quarantinedDelegation({ backend, workspacePath, worktreeRoot, spec }, sharedQuarantine); } @@ -2210,6 +2330,7 @@ async function delegateTask(rawArgs, cancel) { let gitProcessQuarantine = null; const quarantineGitProcessTree = async ({ label, terminationError }) => { gitProcessQuarantine ??= await quarantineLeaseForProcessTree({ + quarantineRoot, lockKey, workspaceLease, backend: label, @@ -2406,7 +2527,7 @@ async function delegateTask(rawArgs, cancel) { // 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(lockKey, { + const quarantine = await markWorkspaceQuarantined(quarantineRoot, lockKey, { backend, workspacePath, worktreeRoot, @@ -2538,10 +2659,12 @@ async function delegateTask(rawArgs, cancel) { deadline, onCancelled: () => cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }), onDeadline: () => lockDeadlineDelegation({ backend, workspacePath, worktreeRoot, spec }), - operatorRecoveryApproved: (owner) => quarantineRecoveryApproved(lockKey, owner), - onAcquired: () => clearQuarantineRecoveryApproval(lockKey), + operatorRecoveryApproved: (owner) => quarantineRecoveryApproved( + quarantineRoot, lockKey, owner, + ), + onAcquired: () => clearQuarantineRecoveryApproval(quarantineRoot, lockKey), isUnavailable: async () => { - observedQuarantine = await readWorkspaceQuarantine(lockKey); + observedQuarantine = await readWorkspaceQuarantine(quarantineRoot, lockKey); return quarantinedWorkspaces.has(lockKey) || Boolean(observedQuarantine); }, onUnavailable: () => quarantinedDelegation( @@ -2730,6 +2853,7 @@ async function handleMessage(message) { let worktreeRoot = ""; let gitCommonDir = ""; let lockStoreRoot = ""; + let quarantineRoot = ""; let repositoryAccess = null; try { workspacePath = await validateWorkspace(args.workspacePath, { cancel }); @@ -2742,6 +2866,7 @@ async function handleMessage(message) { 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) { @@ -2751,7 +2876,7 @@ async function handleMessage(message) { } if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); const lockKey = repositoryAccess.key; - const existingQuarantine = await readWorkspaceQuarantine(lockKey); + const existingQuarantine = await readWorkspaceQuarantine(quarantineRoot, lockKey); if (quarantinedWorkspaces.has(lockKey) || existingQuarantine) { return quarantinedWorkspaceStatus( message.id, { workspacePath, worktreeRoot }, existingQuarantine, @@ -2759,7 +2884,7 @@ async function handleMessage(message) { } let observedQuarantine = null; return await withWorkspaceLock(lockKey, lockStoreRoot, async (workspaceLease) => { - const sharedQuarantine = await readWorkspaceQuarantine(lockKey); + const sharedQuarantine = await readWorkspaceQuarantine(quarantineRoot, lockKey); if (quarantinedWorkspaces.has(lockKey) || sharedQuarantine) { return quarantinedWorkspaceStatus( message.id, { workspacePath, worktreeRoot }, sharedQuarantine, @@ -2768,6 +2893,7 @@ async function handleMessage(message) { let gitProcessQuarantine = null; const quarantineGitProcessTree = async ({ label, terminationError }) => { gitProcessQuarantine ??= await quarantineLeaseForProcessTree({ + quarantineRoot, lockKey, workspaceLease, backend: label, @@ -2803,10 +2929,12 @@ async function handleMessage(message) { }, { cancel, onCancelled: () => cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }), - operatorRecoveryApproved: (owner) => quarantineRecoveryApproved(lockKey, owner), - onAcquired: () => clearQuarantineRecoveryApproval(lockKey), + operatorRecoveryApproved: (owner) => quarantineRecoveryApproved( + quarantineRoot, lockKey, owner, + ), + onAcquired: () => clearQuarantineRecoveryApproval(quarantineRoot, lockKey), isUnavailable: async () => { - observedQuarantine = await readWorkspaceQuarantine(lockKey); + observedQuarantine = await readWorkspaceQuarantine(quarantineRoot, lockKey); return quarantinedWorkspaces.has(lockKey) || Boolean(observedQuarantine); }, onUnavailable: () => quarantinedWorkspaceStatus( diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs index 6c952af..3e3e1a3 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs @@ -38,6 +38,17 @@ if (spec.branchRoundTrip) { } 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"]); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index f48ba35..53962e8 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -12,7 +12,7 @@ import { fileURLToPath } from "node:url"; import { localHostIdentity, WORKSPACE_LOCK_REF_PREFIX, workspaceLockRef, } from "../workspace-lock.mjs"; -import { resolvePathCommand, safeGitInvocation } from "../git-executable.mjs"; +import { resolvePathCommand, safeGitInvocation, subscribePathCommand } from "../git-executable.mjs"; import { backendGitProvenanceEnvironment, closestExistingBase, committedDelta, markWorkspaceQuarantined, populateCommitishCache, readBackendGitProvenance, runCommand, runGitCommand, @@ -43,6 +43,38 @@ test("failed backend command resolutions are retried after installation", async 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("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"); @@ -106,6 +138,56 @@ test("safe Git invocations use an unpopulatable hook sink and ignore inherited r ); }); +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, @@ -757,15 +839,6 @@ test("cancellation in the spawn-to-controller window never launches the backend" await assert.rejects(access(marker), /ENOENT/u); }); -function currentUserLockRoot() { - 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 scope = createHash("sha256").update(identity).digest("hex").slice(0, 20); - return path.join(os.tmpdir(), "minimax-cli-agent-bridge-locks-" + scope); -} - 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, ""))); @@ -798,7 +871,9 @@ async function repositoryKey(canonicalGitCommonDir) { async function repositoryStatePaths(canonicalGitCommonDir) { const key = await repositoryKey(canonicalGitCommonDir); const digest = createHash("sha256").update(key).digest("hex"); - const root = currentUserLockRoot(); + const root = path.join( + canonicalGitCommonDir, "cli-agent-bridge-lock-store.git", "cli-agent-bridge-quarantines", + ); return { root, quarantinePath: path.join(root, digest + ".quarantine"), @@ -882,7 +957,7 @@ function execServer(configPath, extraEnv = {}) { }); } -async function makeHarness(context, { unborn = false } = {}) { +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); @@ -899,7 +974,7 @@ async function makeHarness(context, { unborn = false } = {}) { }, }, })); - const client = new McpClient(configPath); + const client = new McpClient(configPath, extraEnv); await client.initialize(); context.after(async () => { await client.close(); @@ -1112,6 +1187,30 @@ test("the private lock store inherits the repository sharing mode", async (conte ))).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"); } }); @@ -1342,7 +1441,7 @@ test("Git snapshot clean filters remain process-contained", { 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(() => unlink(out.quarantinePath).catch(() => {})); + 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"); @@ -1719,9 +1818,75 @@ test("a quarantine marker blocks delegations in every server process", async (co } }); +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(key, { + const quarantine = await markWorkspaceQuarantined(quarantineRoot, key, { backend: "fixture", terminationError: "descendants remain uncertain", }); context.after(() => rm(quarantine.quarantinePath, { recursive: true, force: true })); @@ -1732,7 +1897,9 @@ test("quarantine publication uses an exclusive directory claim without hard link assert.equal(record.quarantineId, quarantine.quarantineId); assert.equal(record.terminationError, "descendants remain uncertain"); await assert.rejects( - markWorkspaceQuarantined(key, { terminationError: "must not replace the first incident" }), + markWorkspaceQuarantined( + quarantineRoot, key, { terminationError: "must not replace the first incident" }, + ), /quarantine marker already exists/iu, ); }); @@ -1884,6 +2051,44 @@ test("cancellation interrupts workspace filesystem canonicalization", async (con await assert.rejects(access(path.join(workspace, "canonicalization-bypass.txt")), /ENOENT/u); }); +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"); @@ -2302,6 +2507,32 @@ test("a worker ref pointing at a non-commit object is reported without failing t 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"; @@ -2799,7 +3030,7 @@ test("workspace lock metadata is absent from mirrored repository refs", async (c if (!out.ok) { assert.match(out.error, /process tree could not be confirmed terminated/iu); assert.ok(out.quarantinePath, JSON.stringify(out)); - context.after(() => unlink(out.quarantinePath).catch(() => {})); + context.after(() => rm(out.quarantinePath, { recursive: true, force: true })); } const { stdout: mirroredRefs } = await execFileAsync( "git", ["for-each-ref", "--format=%(refname)"], { cwd: mirror }, From 50664e4bd73d162a93d635917692afed7c38b1c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Mon, 17 Aug 2026 18:27:13 +0800 Subject: [PATCH 34/40] fix(cli-agent-bridge): harden configuration and trace handling --- .../cli-agent-bridge/git-executable.mjs | 93 +++- .../Hylouis233/cli-agent-bridge/server.mjs | 472 ++++++++++++++--- .../skills/cli-agent-bridge/SKILL.md | 4 +- .../cli-agent-bridge/tests/fake-backend.mjs | 16 +- .../cli-agent-bridge/tests/server.test.mjs | 485 +++++++++++++++++- .../tests/workspace-lock.test.mjs | 60 ++- .../cli-agent-bridge/workspace-lock.mjs | 53 +- 7 files changed, 1066 insertions(+), 117 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs b/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs index 9ffc9c2..201d160 100644 --- a/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/git-executable.mjs @@ -2,10 +2,20 @@ import { constants } from "node:fs"; import { access, appendFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; -let executablePromise = null; +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, ""); @@ -63,12 +73,12 @@ async function resolvePathCommandUncached(command) { function pathCommandEntry(command) { if (typeof command !== "string" || !command) { - return { promise: Promise.resolve(null), settled: true, value: null, error: null }; + return { promise: Promise.resolve(null), state: "fulfilled", value: null, error: null }; } if (!pathCommandEntries.has(command)) { const entry = { promise: resolvePathCommandUncached(command), - settled: false, + state: "pending", value: null, error: null, waiters: new Set(), @@ -79,20 +89,22 @@ function pathCommandEntry(command) { // permanently stalled filesystem lookup cannot retain one closure per // abandoned request. void entry.promise.then((resolved) => { - entry.settled = true; + entry.state = "fulfilled"; entry.value = resolved; - for (const waiter of entry.waiters) waiter.resolve(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.settled = true; + entry.state = "rejected"; entry.error = error; - for (const waiter of entry.waiters) waiter.reject(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); @@ -104,9 +116,14 @@ export function resolvePathCommand(command) { export function subscribePathCommand(command, resolve, reject) { const entry = pathCommandEntry(command); - if (entry.settled) { - queueMicrotask(() => entry.error ? reject(entry.error) : resolve(entry.value)); - return () => {}; + 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); @@ -114,11 +131,59 @@ export function subscribePathCommand(command, resolve, reject) { } export function trustedGitExecutable() { - executablePromise ??= resolveGitExecutable(); - return executablePromise; + 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 async function safeGitInvocation(args, baseEnvironment = process.env) { +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 @@ -142,5 +207,5 @@ export async function safeGitInvocation(args, baseEnvironment = process.env) { GIT_PAGER: "", PAGER: "", }); - return { command: await trustedGitExecutable(), args: safeArgs, env }; + return { command: resolvedExecutable ?? await trustedGitExecutable(), args: safeArgs, env }; } diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index dc44d3c..3ead9a1 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -8,12 +8,14 @@ import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { chmod, lstat, mkdir, mkdtemp, open, readFile, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises"; +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 } from "./git-executable.mjs"; +import { + resolvePathCommand, safeGitInvocation, subscribePathCommand, subscribeTrustedGitExecutable, +} from "./git-executable.mjs"; import { initializeProcessTree, isProcessTreeAlive, refreshProcessTree, signalProcessTree, waitForChildExit, waitForProcessTreeExit } from "./process-tree.mjs"; import { acquireGitWorkspaceLock, @@ -37,6 +39,10 @@ const KILL_GRACE_MS = Number.isInteger(TEST_KILL_GRACE_MS) && TEST_KILL_GRACE_MS ? 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(); const RAW_TAIL_CHARS = 60_000; const FETCH_PROVENANCE_TIPS = Symbol("fetchProvenanceTips"); const QUARANTINE_RECORD_FILE = "record.json"; @@ -62,9 +68,9 @@ function trustedWindowsPowerShell() { ); } -// Built-in defaults. The sibling backends.json (or the CLI_AGENT_BRIDGE_BACKENDS -// environment variable) overrides these; a missing or invalid file falls back -// to this table. +// 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", @@ -181,32 +187,67 @@ const TOOLS = [ }, ]; -async function loadBackends() { - const override = process.env.CLI_AGENT_BRIDGE_BACKENDS; - const candidates = []; - if (override) candidates.push(path.resolve(override)); - candidates.push(path.join(path.dirname(fileURLToPath(import.meta.url)), "backends.json")); - for (const file of candidates) { +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; +} + +async function readBackendConfiguration(file) { + return validateBackendConfiguration(JSON.parse(await readFile(file, "utf8")), file); +} + +export async function loadBackends() { + 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 { - const raw = await readFile(file, "utf8"); - const parsed = JSON.parse(raw); - const backends = parsed && typeof parsed === "object" && parsed.backends && typeof parsed.backends === "object" - ? parsed.backends - : FALLBACK_BACKENDS; - if (Object.keys(backends).length > 0) { - const configDirectory = path.dirname(file); - return Object.fromEntries(Object.entries(backends).map(([name, spec]) => { - if (!spec || typeof spec.command !== "string" || !/[\\/]/u.test(spec.command)) { - return [name, spec]; - } - return [name, { ...spec, command: path.resolve(configDirectory, spec.command) }]; - })); - } - } catch { - // fall through to the next candidate + return await readBackendConfiguration(file); + } catch (error) { + throw new Error("cannot load explicit backend configuration " + file + ": " + error.message); } } - return FALLBACK_BACKENDS; + const bundled = path.join(path.dirname(fileURLToPath(import.meta.url)), "backends.json"); + try { + return await readBackendConfiguration(bundled); + } catch { + return FALLBACK_BACKENDS; + } } function substituteArgs(template, task, session) { @@ -658,7 +699,7 @@ function interruptibleFilesystemOperation(operation, { cancel = null, deadline = }); } -function resolveBackendCommand(command, options = {}) { +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) { @@ -687,14 +728,23 @@ function resolveBackendCommand(command, options = {}) { reject, new DeadlineExceededError("delegation deadline exceeded"), ), Math.max(0, deadline - Date.now())); } - unsubscribeResolution = subscribePathCommand( - command, + 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 Error("workspacePath must be a non-empty string"); @@ -1109,6 +1159,7 @@ function snapshotFailure(label, result) { class OperationCancelledError extends Error {} class DeadlineExceededError extends Error {} +class InvalidArgumentsError extends Error {} class GitProcessTreeUnconfirmedError extends Error { constructor(label, terminationError, quarantine = null) { super(label + " process tree could not be confirmed terminated: " + terminationError); @@ -1162,58 +1213,203 @@ export function backendGitProvenanceEnvironment(tracePath, baseEnvironment = pro return env; } -async function createBackendGitProvenance(baseEnvironment = process.env) { - const root = await mkdtemp(path.join(os.tmpdir(), "minimax-cli-agent-fetch-")); - const tracePath = path.join(root, "git.trace"); +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 { - await chmod(root, 0o700); - await writeFile(tracePath, "", { flag: "wx", mode: 0o600 }); + 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, tracePath, + root, rootIdentity, tracePath, handle, identity, env: backendGitProvenanceEnvironment(tracePath, baseEnvironment), }; } catch (error) { - await rm(root, { recursive: true, force: true }).catch(() => {}); + 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; } } -export async function readBackendGitProvenance(provenance, worktreeRoot) { - const traceStat = await stat(provenance.tracePath); - if (traceStat.size > MAX_CAPTURE_CHARS) { +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 = {}) { + const captured = Buffer.allocUnsafe(MAX_CAPTURE_CHARS + 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 > MAX_CAPTURE_CHARS) { throw new Error("Git fetch provenance trace exceeded the capture limit"); } - const trace = await readFile(provenance.tracePath, "utf8"); + return new TextDecoder("utf-8", { fatal: true }).decode(captured.subarray(0, offset)); +} + +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) => { const normalized = path.resolve(String(value ?? "")); return process.platform === "win32" ? normalized.toLowerCase() : normalized; }; const targetWorktree = normalizeWorktree(worktreeRoot); - for (const line of trace.split(/\r?\n/u)) { - if (!line.startsWith("{")) continue; - 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 }); + 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"); } - } 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 (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; @@ -1230,6 +1426,32 @@ export async function readBackendGitProvenance(provenance, worktreeRoot) { 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, @@ -1241,9 +1463,12 @@ export async function runGitCommand(args, { commandRunner = runCommand, } = {}) { if (cancel?.cancelled) throw new OperationCancelledError("operation cancelled by client"); - const remaining = deadline === null ? GIT_TIMEOUT_MS : deadline - Date.now(); + 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"); - const git = await safeGitInvocation(args); let controller = null; const result = await commandRunner(git.command, git.args, { cwd, @@ -1722,6 +1947,24 @@ export async function committedDelta(worktreeRoot, before, after, options = {}) }; } +export function backendEntryFromProbe(name, spec, check) { + const available = check.exitCode === 0 && check.treeTerminated === true; + const probeError = check.treeTerminated !== true + ? (check.terminationError || "backend version probe process tree could not be confirmed terminated") + : (check.errorMessage || "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) { const backends = await loadBackends(); const entries = []; @@ -1774,17 +2017,7 @@ async function listBackends(cancel = null) { }, }); if (cancel?.controller) cancel.controller = null; - entries.push({ - name, - label: typeof spec.label === "string" ? spec.label : name, - command: spec.command, - available: check.exitCode === 0, - experimental: Boolean(spec.experimental), - version: check.exitCode === 0 ? tail(check.stdout, 200).trim() : null, - error: check.exitCode === 0 ? "" : (check.errorMessage || "command not found or not executable"), - resumeSupported: Array.isArray(spec.resumeArgs), - notes: typeof spec.notes === "string" ? spec.notes : "", - }); + entries.push(backendEntryFromProbe(name, spec, check)); } return entries; } @@ -2201,9 +2434,15 @@ function quarantinedWorkspaceStatus(id, { workspacePath = "", worktreeRoot = "" } async function delegateTask(rawArgs, cancel) { - const timeoutMs = Number.isInteger(rawArgs?.timeoutMs) - ? Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, rawArgs.timeoutMs)) - : DEFAULT_TIMEOUT_MS; + 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 backends = await loadBackends(); if (!rawArgs || typeof rawArgs.backend !== "string" || !rawArgs.backend.trim()) { @@ -2442,7 +2681,7 @@ async function delegateTask(rawArgs, cancel) { } throw error; } - const remaining = deadline - Date.now(); + let remaining = deadline - Date.now(); if (remaining <= 0) { return { ok: false, @@ -2478,9 +2717,45 @@ async function delegateTask(rawArgs, cancel) { ownershipLostError ??= error; }); let workerLockUpdate = Promise.resolve(); - const gitProvenance = await createBackendGitProvenance(); + 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, @@ -2506,6 +2781,9 @@ async function delegateTask(rawArgs, cancel) { }); }, }); + } 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. @@ -2513,6 +2791,10 @@ async function delegateTask(rawArgs, cancel) { 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 @@ -2559,7 +2841,9 @@ async function delegateTask(rawArgs, cancel) { } if (result.treeTerminated) { try { - fetchProvenanceTips = await readBackendGitProvenance(gitProvenance, worktreeRoot); + 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 @@ -2570,7 +2854,7 @@ async function delegateTask(rawArgs, cancel) { } 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 rm(gitProvenance.root, { recursive: true, force: true }).catch(() => {}); + await cleanupBackendGitProvenance(gitProvenance); } let after = null; let commits = null; @@ -2748,6 +3032,22 @@ async function terminateActiveRequests(reason = "shutdown") { // 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) { @@ -2966,7 +3266,11 @@ async function handleMessage(message) { return jsonRpcError(message.id, -32601, "Method not found: " + String(message.method)); } } catch (error) { - return jsonRpcError(message.id, -32603, error.message); + return jsonRpcError( + message.id, + error instanceof InvalidArgumentsError ? -32602 : -32603, + error.message, + ); } } 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 index 73d9a94..39d28d0 100644 --- a/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -62,7 +62,9 @@ inside the target git repository, and their results come back as a git diff for 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, so still review the returned snapshot. + 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. diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs index 3e3e1a3..566beb6 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { appendFileSync, writeFileSync } from "node:fs"; +import { appendFileSync, unlinkSync, writeFileSync } from "node:fs"; import { execFileSync, spawn } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -118,6 +118,20 @@ if (spec.branchRoundTrip) { 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]); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index 53962e8..8cb2185 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -1,20 +1,24 @@ import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { access, chmod, copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises"; +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 { createInterface } from "node:readline"; import test from "node:test"; import { promisify } from "node:util"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { localHostIdentity, WORKSPACE_LOCK_REF_PREFIX, workspaceLockRef, } from "../workspace-lock.mjs"; -import { resolvePathCommand, safeGitInvocation, subscribePathCommand } from "../git-executable.mjs"; import { - backendGitProvenanceEnvironment, closestExistingBase, committedDelta, markWorkspaceQuarantined, + resolvePathCommand, safeGitInvocation, subscribePathCommand, subscribeTrustedGitExecutable, + trustedGitExecutable, +} from "../git-executable.mjs"; +import { + backendEntryFromProbe, backendGitProvenanceEnvironment, closestExistingBase, committedDelta, + loadBackends, markWorkspaceQuarantined, populateCommitishCache, readBackendGitProvenance, runCommand, runGitCommand, } from "../server.mjs"; @@ -75,6 +79,66 @@ test("abandoned command-resolution waiters detach from the shared lookup", async 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"); @@ -227,6 +291,30 @@ test("Git interruption never outruns unconfirmed descendant quarantine", async ( "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("committed-delta baseline preparation uses bounded batch queries", async () => { const target = "a".repeat(40); const boundary = "b".repeat(40); @@ -942,10 +1030,17 @@ class McpClient { if (this.child.exitCode !== null) return; const exited = new Promise((resolve) => this.child.once("exit", resolve)); this.child.stdin.end(); - await Promise.race([ - exited, - new Promise((_, reject) => setTimeout(() => reject(new Error("server did not exit after stdin closed")), 15_000)), - ]); + 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); + } } } @@ -1023,6 +1118,262 @@ async function waitFor(predicate, timeoutMs = 10_000) { } } +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: "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("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("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("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("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", "--", ""]); @@ -1948,8 +2299,8 @@ test("quarantine recovery requires an explicit incident-bound approval", async ( 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: 700 }), 132); - assert.equal(absentOnly.result.structuredContent.ok, false); + }, { 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); @@ -2809,6 +3160,112 @@ test("direct remote-tracking ref writes do not hide worker-created commits", asy 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("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"); @@ -2869,7 +3326,9 @@ test("successive fetches are detected even after FETCH_HEAD is overwritten", asy await rm(path.join(workspace, ".git", "FETCH_HEAD"), { force: true }); const tracePath = path.join(tempRoot, "successive-fetch.trace2"); - await writeFile(tracePath, ""); + 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", @@ -2882,7 +3341,9 @@ test("successive fetches are detected even after FETCH_HEAD is overwritten", asy 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 }, workspace); + 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"); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs index 16100c0..0948ddf 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFile, execFileSync } from "node:child_process"; import { writeFileSync } from "node:fs"; -import { access, chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { access, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -394,6 +394,64 @@ test("workspace-lock Git operations disable repository 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; diff --git a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs index afb4bec..7152db1 100644 --- a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs @@ -1,9 +1,10 @@ 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 } from "./git-executable.mjs"; +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"; @@ -142,6 +143,38 @@ function checkInterrupted(cancel, deadline) { } } +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, @@ -149,9 +182,21 @@ async function runGit(cwd, args, { returnOnTimeout = false, } = {}) { checkInterrupted(cancel, deadline); - const remaining = deadline === null ? GIT_TIMEOUT_MS : deadline - Date.now(); - const timeoutMs = Math.max(1, Math.min(GIT_TIMEOUT_MS, remaining)); - const git = await safeGitInvocation(args); + 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, From 5db74bfcd0a1ff79a42497e27cb22589a607ffb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Mon, 17 Aug 2026 22:14:59 +0800 Subject: [PATCH 35/40] fix(cli-agent-bridge): fail closed without reliable containment --- plugins/Hylouis233/cli-agent-bridge/README.md | 42 +-- .../Hylouis233/cli-agent-bridge/server.mjs | 302 +++++++++++------ .../skills/cli-agent-bridge/SKILL.md | 3 + .../cli-agent-bridge/test/server.test.mjs | 7 +- .../cli-agent-bridge/tests/server.test.mjs | 313 ++++++++++++++++-- 5 files changed, 514 insertions(+), 153 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 147276c..d0638b0 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -48,11 +48,12 @@ repository lock; use separate clones when the comparison must run in parallel. - 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. -- Supported operating systems: Windows and Linux. The server is plain Node.js; on Windows, - delegated workers run inside a kill-on-close Job Object. End-to-end verified on - Windows (Claude Code 2.1.226, Kimi Code 0.30.0) and validated on Linux in a Node 22 container. - macOS/BSD fail closed before Git or backend launch because this dependency-free server cannot - prove containment of a child that clears its environment and escapes its original session. +- 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 | @@ -123,9 +124,9 @@ you already obtained a valid ID from that backend outside this Plugin. 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. - On Linux, 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 + 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 @@ -133,8 +134,8 @@ you already obtained a valid ID from that backend outside this Plugin. 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 cannot reconstruct descendants that escaped into another POSIX session from the - recorded worker PID alone, so inspect leftover processes before clearing its coordination ref. + 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 @@ -151,18 +152,9 @@ you already obtained a valid ID from that backend outside this Plugin. 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. A lightweight ancestry monitor records descendants that create a new POSIX - session/process group so cancellation still terminates them; tracked PIDs are matched against - their recorded start identity (process start time on POSIX, creation time on Windows) so a - reused PID is never signaled, and a POSIX process group is only signaled while its original - leader identity still matches. On Linux, descendants also inherit a per-run environment marker; - if the parent exits before ancestry polling, the close path uses a bounded observation grace and - marker scans recover children that become visible just after the leader exits, using stable - identities from `/proc`. If the kernel exposes tasks but not their `children` files, a verified - startup capability check switches to a full PID/PPID snapshot while preserving the same marker - and immutable-identity checks. Under extreme Linux process churn, if an identity-stable ancestry - sample cannot be completed, the bridge conservatively quarantines the workspace for the same - operator-verified manual recovery described below. Repository discovery and read-only snapshots + 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 @@ -190,8 +182,6 @@ you already obtained a valid ID from that backend outside this Plugin. 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. - On Linux, zombie-only tracked trees count as terminated; zombies cannot edit the workspace and - may otherwise persist when container PID 1 does not reap them. - 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, @@ -232,8 +222,8 @@ They cover the full MCP flow plus in-process and cross-process canonical worktre 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 -process-tree termination, escaped POSIX descendants and zombie-only Linux groups, PID-reuse -identity checks before signaling, unusual Git pathnames (including a trailing-space worktree +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, 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 diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 3ead9a1..69243b8 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -43,6 +43,7 @@ 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 QUARANTINE_RECORD_FILE = "record.json"; @@ -53,9 +54,18 @@ const TEST_RUNTIME_PLATFORM = process.env.NODE_ENV === "test" 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 === "linux" || platform === "win32"; + 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() { @@ -673,6 +683,12 @@ function interruptibleFilesystemOperation(operation, { cancel = null, deadline = 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; @@ -692,7 +708,7 @@ function interruptibleFilesystemOperation(operation, { cancel = null, deadline = reject, new DeadlineExceededError("delegation deadline exceeded"), ), Math.max(0, deadline - Date.now())); } - Promise.resolve(operation).then( + Promise.resolve(pending).then( (value) => finish(resolve, value), (error) => finish(reject, error), ); @@ -1728,27 +1744,66 @@ export async function populateCommitishCache(worktreeRoot, oids, cache, options 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", target, "--stdin", + "rev-list", "--topo-order", "--boundary", "--parents", target, "--stdin", ], { cwd: worktreeRoot, - ...options, + ...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.startsWith("-")) continue; - const boundary = line.slice(1).trim(); - if (/^[0-9a-f]{40,64}$/u.test(boundary)) return boundary; - } + 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 null; + return boundaries[0]; } export async function committedDelta(worktreeRoot, before, after, options = {}) { @@ -1893,16 +1948,19 @@ export async function committedDelta(worktreeRoot, before, after, options = {}) attributedCommits.set(oid, { oid, subject, labels: new Set(labels) }); } } - // Diff from the closest ancestral pre-run tip, even for an existing ref. - // A force update can move a ref onto a pre-existing descendant lineage; - // using its older (but still ancestral) tip would attribute that lineage's - // already-existing changes to the worker. + // 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) ?? await emptyTree(); + base = await closestExistingBase(worktreeRoot, target, baselineCommits, { + ...options, preferredBase: previousTarget, + }) ?? await emptyTree(); } else if (previousTarget) { base = previousTarget; } else { @@ -1966,6 +2024,19 @@ export function backendEntryFromProbe(name, spec, check) { } async function listBackends(cancel = null) { + 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.", + }]; + } const backends = await loadBackends(); const entries = []; for (const [name, spec] of Object.entries(backends)) { @@ -1973,20 +2044,6 @@ async function listBackends(cancel = null) { // the discovery call, terminating the current probe and skipping the rest. if (cancel?.cancelled) break; if (!spec || typeof spec.command !== "string") continue; - if (!supportsReliableProcessContainment()) { - entries.push({ - name, - label: typeof spec.label === "string" ? spec.label : name, - command: spec.command, - available: false, - experimental: Boolean(spec.experimental), - version: null, - error: "unsupported platform: reliable descendant containment is available only on Windows and Linux", - resumeSupported: Array.isArray(spec.resumeArgs), - notes: typeof spec.notes === "string" ? spec.notes : "", - }); - continue; - } let resolvedCommand; try { resolvedCommand = await resolveBackendCommand(spec.command, { cancel }); @@ -2031,26 +2088,41 @@ function workspaceQuarantineRecoveryPath(quarantineRoot, key) { return workspaceQuarantinePath(quarantineRoot, key) + ".recovery-approved"; } -async function readWorkspaceQuarantine(quarantineRoot, key) { +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 { - const marker = await stat(quarantinePath); + 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 readFile(path.join(quarantinePath, QUARANTINE_RECORD_FILE), "utf8"); + 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 readFile(quarantinePath, "utf8"); + 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 realpath(quarantinePath), details }; + return { quarantinePath: await step(() => fsOps.realpath(quarantinePath)), details }; } catch (error) { if (error.code === "ENOENT") return null; throw error; @@ -2061,23 +2133,30 @@ async function readWorkspaceQuarantine(quarantineRoot, key) { // 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) { +async function quarantineRecoveryApproved(quarantineRoot, key, owner, options = {}) { + const step = (operation) => interruptibleFilesystemOperation(operation, options); try { const recoveryPath = workspaceQuarantineRecoveryPath(quarantineRoot, key); - const marker = await stat(recoveryPath); + const marker = await step(() => stat(recoveryPath)); const raw = marker.isDirectory() - ? await readFile(path.join(recoveryPath, QUARANTINE_RECORD_FILE), "utf8") - : await readFile(recoveryPath, "utf8"); + ? 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 { + } catch (error) { + if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError) { + throw error; + } return false; } } -async function clearQuarantineRecoveryApproval(quarantineRoot, key) { - await rm(workspaceQuarantineRecoveryPath(quarantineRoot, key), { recursive: true, force: true }); +async function clearQuarantineRecoveryApproval(quarantineRoot, key, options = {}) { + await interruptibleFilesystemOperation( + () => rm(workspaceQuarantineRecoveryPath(quarantineRoot, key), { recursive: true, force: true }), + options, + ); } export async function markWorkspaceQuarantined( @@ -2293,6 +2372,14 @@ async function withWorkspaceLock(key, lockStoreRoot, fn, { } 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) { @@ -2434,6 +2521,26 @@ function quarantinedWorkspaceStatus(id, { workspacePath = "", worktreeRoot = "" } 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)) { @@ -2456,27 +2563,6 @@ async function delegateTask(rawArgs, cancel) { if (typeof rawArgs.task !== "string" || !rawArgs.task.trim()) { throw new Error("task must be a non-empty string"); } - if (!supportsReliableProcessContainment()) { - return { - ok: false, - error: "delegate_task is unsupported on " + RUNTIME_PLATFORM + - ": reliable descendant containment is available only on Windows and Linux", - 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 backendCommand; try { backendCommand = await resolveBackendCommand(spec.command, { cancel, deadline }); @@ -2553,33 +2639,39 @@ async function delegateTask(rawArgs, cancel) { } const lockKey = repositoryAccess.key; try { - const existingQuarantine = await readWorkspaceQuarantine(quarantineRoot, lockKey); + 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); - 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; + 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; // Attribution window for concurrency disclosure: everything between the // before-snapshot and the after-snapshot. const attributionWindowStart = Date.now(); @@ -2944,17 +3036,29 @@ async function delegateTask(rawArgs, cancel) { onCancelled: () => cancelledDelegation({ backend, workspacePath, worktreeRoot, spec }), onDeadline: () => lockDeadlineDelegation({ backend, workspacePath, worktreeRoot, spec }), operatorRecoveryApproved: (owner) => quarantineRecoveryApproved( - quarantineRoot, lockKey, owner, + quarantineRoot, lockKey, owner, { cancel, deadline }, + ), + onAcquired: () => clearQuarantineRecoveryApproval( + quarantineRoot, lockKey, { cancel, deadline }, ), - onAcquired: () => clearQuarantineRecoveryApproval(quarantineRoot, lockKey), isUnavailable: async () => { - observedQuarantine = await readWorkspaceQuarantine(quarantineRoot, lockKey); + 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(() => {}); } @@ -3134,8 +3238,7 @@ async function handleMessage(message) { if (!supportsReliableProcessContainment()) { const out = { ok: false, - error: "workspace_status is unsupported on " + RUNTIME_PLATFORM + - ": reliable Git helper containment is available only on Windows and Linux", + error: unsupportedPlatformMessage("workspace_status"), cancelled: false, workspacePath: typeof args.workspacePath === "string" ? args.workspacePath : "", worktreeRoot: "", @@ -3176,7 +3279,9 @@ async function handleMessage(message) { } if (cancel.cancelled) return cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }); const lockKey = repositoryAccess.key; - const existingQuarantine = await readWorkspaceQuarantine(quarantineRoot, lockKey); + const existingQuarantine = await readWorkspaceQuarantine( + quarantineRoot, lockKey, { cancel }, + ); if (quarantinedWorkspaces.has(lockKey) || existingQuarantine) { return quarantinedWorkspaceStatus( message.id, { workspacePath, worktreeRoot }, existingQuarantine, @@ -3184,7 +3289,9 @@ async function handleMessage(message) { } let observedQuarantine = null; return await withWorkspaceLock(lockKey, lockStoreRoot, async (workspaceLease) => { - const sharedQuarantine = await readWorkspaceQuarantine(quarantineRoot, lockKey); + const sharedQuarantine = await readWorkspaceQuarantine( + quarantineRoot, lockKey, { cancel }, + ); if (quarantinedWorkspaces.has(lockKey) || sharedQuarantine) { return quarantinedWorkspaceStatus( message.id, { workspacePath, worktreeRoot }, sharedQuarantine, @@ -3230,17 +3337,26 @@ async function handleMessage(message) { cancel, onCancelled: () => cancelledWorkspaceStatus(message.id, { workspacePath, worktreeRoot }), operatorRecoveryApproved: (owner) => quarantineRecoveryApproved( - quarantineRoot, lockKey, owner, + quarantineRoot, lockKey, owner, { cancel }, + ), + onAcquired: () => clearQuarantineRecoveryApproval( + quarantineRoot, lockKey, { cancel }, ), - onAcquired: () => clearQuarantineRecoveryApproval(quarantineRoot, lockKey), isUnavailable: async () => { - observedQuarantine = await readWorkspaceQuarantine(quarantineRoot, lockKey); + 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(); 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 index 39d28d0..d4dfd36 100644 --- a/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md +++ b/plugins/Hylouis233/cli-agent-bridge/skills/cli-agent-bridge/SKILL.md @@ -53,6 +53,9 @@ inside the target git repository, and their results come back as a git diff for - 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 diff --git a/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs index ea51f8d..38fb82d 100644 --- a/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs @@ -18,7 +18,12 @@ const server = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "se function startServer(extraEnv = {}) { const child = spawn(process.execPath, [server], { - env: { ...process.env, ...extraEnv }, + env: { + ...process.env, + NODE_ENV: "test", + CLI_AGENT_BRIDGE_TEST_PROCESS_TREE_MODE: "1", + ...extraEnv, + }, stdio: ["pipe", "pipe", "pipe"], }); let buf = ""; diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index 8cb2185..6bafc8b 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -19,7 +19,7 @@ import { import { backendEntryFromProbe, backendGitProvenanceEnvironment, closestExistingBase, committedDelta, loadBackends, markWorkspaceQuarantined, - populateCommitishCache, readBackendGitProvenance, runCommand, runGitCommand, + populateCommitishCache, readBackendGitProvenance, readWorkspaceQuarantine, runCommand, runGitCommand, } from "../server.mjs"; const execFileAsync = promisify(execFile); @@ -971,7 +971,11 @@ async function repositoryStatePaths(canonicalGitCommonDir) { class McpClient { constructor(configPath, extraEnv = {}) { - this.child = execServer(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; @@ -1383,48 +1387,76 @@ test("Codex templates delimit option-looking task text", async () => { assert.match(source, /resumeArgs: \["exec", "resume", "", "--", ""\]/u); }); -test("unsupported POSIX platforms fail before probing Git or a backend", async (context) => { +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"); - 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: "darwin", - }); - await client.initialize(); context.after(async () => { - await client.close(); await rm(root, { recursive: true, force: true }); }); - const listed = await client.request("tools/call", { - name: "list_backends", arguments: {}, - }); - const backend = listed.result.structuredContent.backends[0]; - assert.equal(backend.available, false); - assert.equal(backend.version, null); - assert.match(backend.error, /unsupported platform/iu); - - const delegated = await client.request("tools/call", taskArguments(workspace, { name: "run" })); - const out = delegated.result.structuredContent; - assert.equal(out.ok, false); - assert.match(out.error, /unsupported on darwin/iu); + 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"); - await assert.rejects(access(marker), /ENOENT/u); + 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", { @@ -2402,6 +2434,103 @@ test("cancellation interrupts workspace filesystem canonicalization", async (con 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"); @@ -2951,9 +3080,126 @@ test("a ref moved from a blob to a new commit uses a commit-safe diff base", asy assert.doesNotMatch(commits.diffStat, new RegExp(blobOid.trim(), "u")); }); -test("a force-moved ref diffs from an ancestral pre-run baseline", async (context) => { +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", ["branch", "force-target"], { cwd: workspace }); + 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 }); @@ -2973,6 +3219,7 @@ test("a force-moved ref diffs from an ancestral pre-run baseline", async (contex 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) => { From 6be15eb9d9f12dd7118fdb48d466c4882bd30598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Mon, 17 Aug 2026 23:56:08 +0800 Subject: [PATCH 36/40] fix(cli-agent-bridge): harden interrupted setup and lock recovery --- .../cli-agent-bridge/config-reader.mjs | 46 +++ .../Hylouis233/cli-agent-bridge/server.mjs | 134 +++++++- .../cli-agent-bridge/tests/server.test.mjs | 172 +++++++++- .../tests/workspace-lock.test.mjs | 295 +++++++++++++++++- .../cli-agent-bridge/workspace-lock.mjs | 232 +++++++++++--- 5 files changed, 803 insertions(+), 76 deletions(-) create mode 100644 plugins/Hylouis233/cli-agent-bridge/config-reader.mjs 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/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 69243b8..c21aee4 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -31,6 +31,7 @@ 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) @@ -232,11 +233,85 @@ function trackBackgroundFinalizer(promise) { return finalizer; } -async function readBackendConfiguration(file) { - return validateBackendConfiguration(JSON.parse(await readFile(file, "utf8")), file); +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"; + 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 async function loadBackends() { +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 result = await runCommand(process.execPath, [ + path.join(path.dirname(fileURLToPath(import.meta.url)), "config-reader.mjs"), file, + ], { + env: backendConfigurationReaderEnvironment(), + 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 (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.treeTerminated !== true) { + throw new Error( + "backend configuration reader cleanup could not be confirmed: " + + (result.terminationError || "process tree termination was unconfirmed"), + ); + } + 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", ); @@ -247,15 +322,21 @@ export async function loadBackends() { } const file = path.resolve(override); try { - return await readBackendConfiguration(file); + return await readBackendConfiguration(file, options); } catch (error) { + if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError) { + 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); - } catch { + return await readBackendConfiguration(bundled, options); + } catch (error) { + if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError) { + throw error; + } return FALLBACK_BACKENDS; } } @@ -802,7 +883,7 @@ async function requireGitRepo(workspacePath, options = {}) { } } -async function gitWorktreeRoot(workspacePath, options = {}) { +export async function gitWorktreeRoot(workspacePath, options = {}) { const result = await runGitCommand(["rev-parse", "--show-toplevel"], { cwd: workspacePath, ...options, }); @@ -814,13 +895,15 @@ async function gitWorktreeRoot(workspacePath, options = {}) { throw new Error("cannot identify Git worktree root: " + (failure || "empty output")); } try { - return await interruptibleFilesystemOperation(realpath(output), options); + 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); } } -async function gitCommonDirectory(workspacePath, options = {}) { +export async function gitCommonDirectory(workspacePath, options = {}) { const result = await runGitCommand(["rev-parse", "--git-common-dir"], { cwd: workspacePath, ...options, }); @@ -830,10 +913,12 @@ async function gitCommonDirectory(workspacePath, options = {}) { throw new Error("cannot identify Git common directory: " + (failure || "empty output")); } try { + const canonicalize = options.fsOps?.realpath ?? realpath; return await interruptibleFilesystemOperation( - realpath(path.resolve(workspacePath, output)), options, + () => 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); } } @@ -2037,7 +2122,13 @@ async function listBackends(cancel = null) { notes: "No backend configuration or executable was inspected.", }]; } - const backends = await loadBackends(); + let backends; + try { + backends = await loadBackends({ cancel }); + } catch (error) { + if (error instanceof OperationCancelledError) return []; + 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 @@ -2551,7 +2642,26 @@ async function delegateTask(rawArgs, cancel) { } const timeoutMs = hasTimeout ? rawArgs.timeoutMs : DEFAULT_TIMEOUT_MS; const deadline = Date.now() + timeoutMs; - const backends = await loadBackends(); + 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 Error("backend must be a non-empty string"); } diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index 6bafc8b..840d91f 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -4,6 +4,7 @@ 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 { createServer } from "node:net"; import { createInterface } from "node:readline"; import test from "node:test"; import { promisify } from "node:util"; @@ -17,8 +18,9 @@ import { trustedGitExecutable, } from "../git-executable.mjs"; import { - backendEntryFromProbe, backendGitProvenanceEnvironment, closestExistingBase, committedDelta, - loadBackends, markWorkspaceQuarantined, + backendConfigurationReaderEnvironment, backendEntryFromProbe, backendGitProvenanceEnvironment, + closestExistingBase, committedDelta, + gitCommonDirectory, gitWorktreeRoot, loadBackends, markWorkspaceQuarantined, populateCommitishCache, readBackendGitProvenance, readWorkspaceQuarantine, runCommand, runGitCommand, } from "../server.mjs"; @@ -315,6 +317,47 @@ test("Git commands do not launch after invocation setup crosses the deadline", a 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); @@ -1171,6 +1214,8 @@ test("explicit backend configuration overrides fail closed atomically", async (c { 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: {} }) }, @@ -1202,6 +1247,27 @@ test("explicit backend configuration overrides fail closed atomically", async (c } }); +test("backend configuration helper control environment excludes inherited injection", () => { + 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" } : {}), + SystemRoot: "C:\\Windows", + }); +}); + 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; @@ -1214,6 +1280,108 @@ test("an unset backend override still loads the bundled configuration", async (c 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.deepEqual(cancelled.result.structuredContent.backends, []); + + 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)); + 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); + await nextConnection(); + const cancelledAt = Date.now(); + cancelledClient.notify("notifications/cancelled", { requestId: 50_201 }); + const cancelled = await listing; + assert.deepEqual(cancelled.result.structuredContent.backends, []); + assert.ok(Date.now() - cancelledAt < 1_500, + "cancelling a named-pipe config read must terminate its managed helper"); + await cancelledClient.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_202); + void pending.catch(() => {}); + 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"); +}); + 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"); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs index 0948ddf..614b254 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs @@ -1,6 +1,5 @@ import assert from "node:assert/strict"; import { execFile, execFileSync } from "node:child_process"; -import { writeFileSync } from "node:fs"; import { access, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -12,12 +11,45 @@ import { localHostIdentity, tryAcquireGitWorkspaceLock, 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, @@ -228,6 +260,178 @@ test("a failed release can be recovered by the next holder in every completed st } }); +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("double release failure is recoverable only by the same module's exact owner record", 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"); + await recovered.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"]); @@ -270,7 +474,7 @@ test("periodic ownership probes do not create heartbeat blobs", async (context) await result.lease.release(); }); -test("post-CAS cancellation remains recoverable when its compensating delete fails", async (context) => { +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); @@ -278,21 +482,86 @@ test("post-CAS cancellation remains recoverable when its compensating delete fai const refPath = path.join(gitDirectory, ...ref.split("/")); await mkdir(path.dirname(refPath), { recursive: true }); const blocker = refPath + ".lock"; - let cancellationChecks = 0; - const cancel = { - get cancelled() { - cancellationChecks += 1; - if (cancellationChecks === 7) writeFileSync(blocker, "intentional compensating-delete failure\n"); - return cancellationChecks >= 7; - }, + 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( - tryAcquireGitWorkspaceLock({ cwd: repo, key, cancel, heartbeatMs: 60_000 }), - /cancelled/iu, + execFileAsync("git", ["rev-parse", "--verify", ref], { cwd: repo }), /Command failed/u, ); - assert.equal(cancellationChecks, 7, "cancellation must be observed only after the CAS commits"); - 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(); diff --git a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs index 7152db1..2feee19 100644 --- a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs @@ -8,7 +8,8 @@ import { safeGitInvocation, subscribeTrustedGitExecutable } from "./git-executab export const WORKSPACE_LOCK_REF_PREFIX = "refs/cli-agent-bridge/workspace-locks/"; const WORKSPACE_HISTORY_REF_SUFFIX = ".history"; -const WORKSPACE_RECOVERY_REF_SUFFIX = ".recovery"; +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; @@ -17,6 +18,7 @@ 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 {} @@ -64,27 +66,57 @@ async function writeRunHistory(cwd, lockRef, owner) { } } +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 = lockRef + WORKSPACE_RECOVERY_REF_SUFFIX; + const recoveryRef = workspaceRecoveryRef(lockRef, ownerOid); const record = { version: 1, lockRef, ownerOid, ownerToken, - authorizedAt: Date.now(), }; const recordOid = await writeOwnerBlob(cwd, record); - const result = await runGit(cwd, ["update-ref", "--no-deref", recoveryRef, recordOid]); - if (result.exitCode !== 0) { - throw new Error("cannot persist workspace lock recovery authorization: " + ( - result.stderr.trim() || "git update-ref exited with code " + String(result.exitCode) - )); + 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 }; + return { ref: recoveryRef, oid: recordOid, owner: record, legacy: false }; } -async function readRecoveryAuthorization(cwd, lockRef, options = {}) { - return await readCurrentOwner(cwd, lockRef + WORKSPACE_RECOVERY_REF_SUFFIX, options); +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) { @@ -143,6 +175,18 @@ function checkInterrupted(cancel, deadline) { } } +function localAbandonmentKey(cwd, ref) { + return String(cwd) + "\0" + ref; +} + +function clearExactLocalAbandonment(cwd, ref, ownerOid, ownerToken) { + const key = localAbandonmentKey(cwd, ref); + const abandoned = locallyAbandonedRefs.get(key); + if (abandoned?.ownerOid === ownerOid && abandoned.ownerToken === ownerToken) { + locallyAbandonedRefs.delete(key); + } +} + function resolveTrustedGitExecutable(cancel, deadline) { checkInterrupted(cancel, deadline); return new Promise((resolve, reject) => { @@ -247,6 +291,23 @@ async function runGit(cwd, args, { 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"); @@ -321,6 +382,62 @@ async function compareAndDelete(cwd, ref, expectedOid) { )); } +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 }); + 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, @@ -525,30 +642,15 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { if (observed && observed.owner?.token === ownerToken) currentOid = observed.oid; } catch { /* best effort; the delete below still uses the last known OID */ } } - // Persist authorization before deleting. If deletion exhausts its - // retries, every bridge process sharing this lock store can safely CAS - // away only this exact completed owner record. - const recoveryAuthorization = await writeRecoveryAuthorization( + // Retry recovery publication and the exact owner delete together. A + // transient recovery-ref lock must not prevent deletion of our owner + // ref; if deletion also fails, a successfully published authorization + // lets the next request safely recover this exact OID and token. + const { deleted } = await removeOwnedRefWithRecovery( cwd, ref, currentOid, ownerToken, ); - let deleted = false; - let deleteError = null; - for (let attempt = 0; attempt < RELEASE_ATTEMPTS; attempt += 1) { - try { - deleted = await compareAndDelete(cwd, ref, currentOid); - deleteError = null; - break; - } catch (error) { - deleteError = error; - if (attempt + 1 < RELEASE_ATTEMPTS) { - await new Promise((resolve) => setTimeout(resolve, RELEASE_RETRY_MS)); - } - } - } - if (deleteError) throw deleteError; released = true; if (deleted) await writeRunHistory(cwd, ref, currentOwner); - await clearRecoveryAuthorization(cwd, recoveryAuthorization); await maintainLockStore(cwd); if (lostError) throw lostError; if (!deleted) throw new Error("workspace lock ownership changed before release"); @@ -580,14 +682,21 @@ export async function tryAcquireGitWorkspaceLock({ checkInterrupted(cancel, deadline); const ref = workspaceLockRef(key); const current = await readCurrentOwner(cwd, ref, { cancel, deadline }); - const recovery = await readRecoveryAuthorization(cwd, ref, { cancel, deadline }); - const sharedRecoveryAuthorized = Boolean( - current && recovery?.owner?.version === 1 && - recovery.owner.lockRef === ref && - recovery.owner.ownerOid === current.oid && - recovery.owner.ownerToken === current.owner?.token, + const abandonmentKey = localAbandonmentKey(cwd, ref); + const localAbandonment = locallyAbandonedRefs.get(abandonmentKey); + const localRecoveryAuthorized = Boolean( + current && localAbandonment?.ownerOid === current.oid && + localAbandonment.ownerToken === current.owner?.token, ); - if (current && !sharedRecoveryAuthorized && !await canReclaim(current.owner, { + if (!localRecoveryAuthorized && localAbandonment) locallyAbandonedRefs.delete(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" }; @@ -595,20 +704,45 @@ export async function tryAcquireGitWorkspaceLock({ const owner = makeOwner({ hostIdentity, ownerPid, ownerIdentity, now }); const newOid = await writeOwnerBlob(cwd, owner, { cancel, deadline }); let acquired = false; - 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 }); + 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" }; + if (localRecoveryAuthorized) locallyAbandonedRefs.delete(abandonmentKey); if (recovery) { - await clearRecoveryAuthorization(cwd, { - ref: ref + WORKSPACE_RECOVERY_REF_SUFFIX, - oid: recovery.oid, - }); + await clearRecoveryAuthorization(cwd, recovery); } const lease = createLease({ cwd, ref, oid: newOid, owner, heartbeatMs }); try { From 05bfcffd87e40a94a5379540841af3dc9ddc43da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Tue, 18 Aug 2026 00:18:18 +0800 Subject: [PATCH 37/40] fix(cli-agent-bridge): reconcile uncertain cleanup outcomes --- .../cli-agent-bridge/process-tree-runner.mjs | 17 ++- .../Hylouis233/cli-agent-bridge/server.mjs | 61 +++++++-- .../cli-agent-bridge/tests/server.test.mjs | 127 +++++++++++++++++- .../tests/workspace-lock.test.mjs | 95 ++++++++++++- .../cli-agent-bridge/workspace-lock.mjs | 103 +++++++++----- 5 files changed, 345 insertions(+), 58 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree-runner.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree-runner.mjs index badaa72..86cffaf 100644 --- a/plugins/Hylouis233/cli-agent-bridge/process-tree-runner.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree-runner.mjs @@ -9,6 +9,9 @@ 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 = () => { @@ -116,7 +119,7 @@ function launchWindowsCmd(commandFile, payload) { let worker; try { worker = spawn(commandProcessor, ["/d", "/s", "/c", `"${shellCommand}"`], { - cwd: process.cwd(), env: process.env, windowsHide: true, + cwd: process.cwd(), env: workerEnvironment(payload), windowsHide: true, windowsVerbatimArguments: true, stdio: [payload.stdinText === undefined ? "ignore" : "pipe", "inherit", "inherit"], }); @@ -136,7 +139,7 @@ function launchWindowsNpmShim(entry, payload) { let worker; try { worker = spawn(process.execPath, [entry, ...payload.args], { - cwd: process.cwd(), env: process.env, windowsHide: true, + cwd: process.cwd(), env: workerEnvironment(payload), windowsHide: true, stdio: [payload.stdinText === undefined ? "ignore" : "pipe", "inherit", "inherit"], }); } catch (error) { @@ -164,7 +167,7 @@ function launchWindowsPowerShell(payload) { worker = spawn(powershell, [ "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", runner, ], { - cwd: process.cwd(), env: process.env, windowsHide: true, + cwd: process.cwd(), env: workerEnvironment(payload), windowsHide: true, stdio: ["pipe", "inherit", "inherit"], }); } catch (error) { @@ -232,7 +235,7 @@ process.stdin.on("end", () => { try { worker = spawn(command, args, { cwd: process.cwd(), - env: process.env, + env: workerEnvironment(payload), windowsHide: true, stdio: [payload.stdinText === undefined ? "ignore" : "pipe", "inherit", "inherit"], }); @@ -270,7 +273,11 @@ process.stdin.on("end", () => { if (typeof payload.command !== "string" || !Array.isArray(payload.args) || payload.command.includes("\0") || payload.args.some((argument) => - typeof argument !== "string" || argument.includes("\0"))) { + 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; diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index c21aee4..d6315fc 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -238,7 +238,16 @@ export function backendConfigurationReaderEnvironment(source = process.env) { // 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"; + 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]) => @@ -259,6 +268,23 @@ export function backendConfigurationReaderEnvironment(source = process.env) { 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) { @@ -273,10 +299,14 @@ async function readBackendConfiguration(file, options = {}) { : options.deadline - Date.now(); if (remaining <= 0) throw new DeadlineExceededError("delegation deadline exceeded"); let controller = null; - const result = await runCommand(process.execPath, [ + 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: backendConfigurationReaderEnvironment(), + env: backendConfigurationControlEnvironment(), + exactEnvironment: readerEnvironment, + forwardExactEnvironment: true, timeoutMs: Math.min(BACKEND_CONFIG_READ_TIMEOUT_MS, remaining), manageProcessTree: true, shouldCancel: () => Boolean(options.cancel?.cancelled), @@ -286,6 +316,12 @@ async function readBackendConfiguration(file, options = {}) { }, }); if (options.cancel?.controller === controller) options.cancel.controller = null; + if (result.treeTerminated !== true) { + throw new Error( + "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"); } @@ -293,12 +329,6 @@ async function readBackendConfiguration(file, options = {}) { Date.now() >= options.deadline) { throw new DeadlineExceededError("delegation deadline exceeded"); } - if (result.treeTerminated !== true) { - throw new Error( - "backend configuration reader cleanup could not be confirmed: " + - (result.terminationError || "process tree termination was unconfirmed"), - ); - } 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"); @@ -389,11 +419,6 @@ export async function runCommand(command, args, options = {}) { options.processTreeTestMode !== true; const useProcessTreeRunner = manageProcessTree && process.platform !== "win32" && !shellArgs; const trackProcessTree = manageProcessTree && !useWindowsJobRunner; - const processTreeRunnerPayload = (useWindowsJobRunner || useProcessTreeRunner) ? JSON.stringify({ - command, - args: argv, - ...(options.stdinText === undefined ? {} : { stdinText: options.stdinText }), - }) : ""; const linuxRunMarker = manageProcessTree && process.platform === "linux" ? randomUUID() : null; @@ -401,6 +426,14 @@ export async function runCommand(command, args, options = {}) { 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", diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index 840d91f..3e32907 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -18,7 +18,8 @@ import { trustedGitExecutable, } from "../git-executable.mjs"; import { - backendConfigurationReaderEnvironment, backendEntryFromProbe, backendGitProvenanceEnvironment, + backendConfigurationControlEnvironment, backendConfigurationReaderEnvironment, + backendEntryFromProbe, backendGitProvenanceEnvironment, closestExistingBase, committedDelta, gitCommonDirectory, gitWorktreeRoot, loadBackends, markWorkspaceQuarantined, populateCommitishCache, readBackendGitProvenance, readWorkspaceQuarantine, runCommand, runGitCommand, @@ -1247,7 +1248,7 @@ test("explicit backend configuration overrides fail closed atomically", async (c } }); -test("backend configuration helper control environment excludes inherited injection", () => { +test("backend configuration helper control environment excludes inherited injection", async () => { const env = backendConfigurationReaderEnvironment({ SystemRoot: "C:\\Windows", PATH: "trusted-path", @@ -1263,9 +1264,90 @@ test("backend configuration helper control environment excludes inherited inject assert.deepEqual(env, { LANG: "C.UTF-8", LC_ALL: "C.UTF-8", - ...(process.platform === "win32" ? { PATHEXT: ".COM;.EXE;.BAT;.CMD" } : {}), + ...(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_BACKENDS = config; + 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; + }); + 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); }); test("an unset backend override still loads the bundled configuration", async (context) => { @@ -1348,6 +1430,18 @@ test("Windows config reader termination closes real named-pipe I/O", { 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(); @@ -1359,27 +1453,48 @@ test("Windows config reader termination closes real named-pipe I/O", { const listing = cancelledClient.request("tools/call", { name: "list_backends", arguments: {}, }, 50_201); - await nextConnection(); + const cancelledSocket = await nextConnection(); const cancelledAt = Date.now(); cancelledClient.notify("notifications/cancelled", { requestId: 50_201 }); const cancelled = await listing; assert.deepEqual(cancelled.result.structuredContent.backends, []); 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_202); + }, 50_203); void pending.catch(() => {}); - await nextConnection(); + 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) => { diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs index 614b254..3d5b5ee 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFile, execFileSync } from "node:child_process"; -import { access, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +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"; @@ -286,7 +286,7 @@ test("a recovery-authorization failure cannot prevent exact owner deletion", asy await second.lease.release(); }); -test("double release failure is recoverable only by the same module's exact owner record", async (context) => { +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 }); @@ -323,7 +323,41 @@ test("double release failure is recoverable only by the same module's exact owne 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"); - await recovered.lease.release(); + 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) => { @@ -567,6 +601,61 @@ test("post-CAS deadline reconciliation deletes the exact committed owner", async 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; diff --git a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs index 2feee19..16ba70b 100644 --- a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs @@ -175,16 +175,12 @@ function checkInterrupted(cancel, deadline) { } } -function localAbandonmentKey(cwd, ref) { - return String(cwd) + "\0" + ref; +function localAbandonmentKey(cwd, ref, ownerOid, ownerToken) { + return String(cwd) + "\0" + ref + "\0" + ownerOid + "\0" + ownerToken; } function clearExactLocalAbandonment(cwd, ref, ownerOid, ownerToken) { - const key = localAbandonmentKey(cwd, ref); - const abandoned = locallyAbandonedRefs.get(key); - if (abandoned?.ownerOid === ownerOid && abandoned.ownerToken === ownerToken) { - locallyAbandonedRefs.delete(key); - } + locallyAbandonedRefs.delete(localAbandonmentKey(cwd, ref, ownerOid, ownerToken)); } function resolveTrustedGitExecutable(cancel, deadline) { @@ -429,7 +425,7 @@ async function removeOwnedRefWithRecovery(cwd, ref, ownerOid, ownerToken) { // 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 }); + locallyAbandonedRefs.set(localAbandonmentKey(cwd, ref, ownerOid, ownerToken), true); throw new AggregateError( [authorizationError, deleteError], "cannot persist recovery authorization or delete the workspace lock ref", @@ -515,6 +511,7 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { let retained = false; let lostError = null; let interruptedError = null; + let interruptedTransition = null; let resolveLost; const lost = new Promise((resolve) => { resolveLost = resolve; }); let heartbeatPending = false; @@ -538,11 +535,15 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { if (stopped || lostError || interruptedError) return; const nextOwner = { ...currentOwner, ...change, heartbeatAt: Date.now() }; const nextOid = await writeOwnerBlob(cwd, nextOwner, interrupt); - if (!await compareAndSwap(cwd, ref, nextOid, currentOid, 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; @@ -633,24 +634,68 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { released = true; return; } - // An interrupted state update may have committed its CAS after the git - // process was killed, leaving currentOid stale. Resync by owner token - // so cleanup still deletes this process's own lease record. - if (interruptedError) { + // 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 && observed.owner?.token === ownerToken) currentOid = observed.oid; - } catch { /* best effort; the delete below still uses the last known OID */ } + if (observed?.owner?.token === ownerToken) { + candidateOwners.set(observed.oid, observed.owner); + } + } catch { /* the exact candidate set remains sufficient */ } + } + let deleted = false; + let deletedOwner = currentOwner; + const removalErrors = []; + for (const [candidateOid, candidateOwner] of candidateOwners) { + try { + const result = await removeOwnedRefWithRecovery( + cwd, ref, candidateOid, ownerToken, + ); + if (result.deleted) { + deleted = true; + deletedOwner = candidateOwner; + 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"); } - // Retry recovery publication and the exact owner delete together. A - // transient recovery-ref lock must not prevent deletion of our owner - // ref; if deletion also fails, a successfully published authorization - // lets the next request safely recover this exact OID and token. - const { deleted } = await removeOwnedRefWithRecovery( - cwd, ref, currentOid, ownerToken, - ); released = true; - if (deleted) await writeRunHistory(cwd, ref, currentOwner); + if (deleted) await writeRunHistory(cwd, ref, deletedOwner); await maintainLockStore(cwd); if (lostError) throw lostError; if (!deleted) throw new Error("workspace lock ownership changed before release"); @@ -682,13 +727,11 @@ export async function tryAcquireGitWorkspaceLock({ checkInterrupted(cancel, deadline); const ref = workspaceLockRef(key); const current = await readCurrentOwner(cwd, ref, { cancel, deadline }); - const abandonmentKey = localAbandonmentKey(cwd, ref); - const localAbandonment = locallyAbandonedRefs.get(abandonmentKey); - const localRecoveryAuthorized = Boolean( - current && localAbandonment?.ownerOid === current.oid && - localAbandonment.ownerToken === current.owner?.token, - ); - if (!localRecoveryAuthorized && localAbandonment) locallyAbandonedRefs.delete(abandonmentKey); + 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 }, From 51437d9e324aae50c4fbfd9c64e58092a33a45e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Tue, 18 Aug 2026 00:22:33 +0800 Subject: [PATCH 38/40] fix(cli-agent-bridge): preserve config cleanup failures --- .../Hylouis233/cli-agent-bridge/server.mjs | 9 +++-- .../cli-agent-bridge/tests/server.test.mjs | 38 ++++++++++--------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index d6315fc..adf5bd9 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -317,7 +317,7 @@ async function readBackendConfiguration(file, options = {}) { }); if (options.cancel?.controller === controller) options.cancel.controller = null; if (result.treeTerminated !== true) { - throw new Error( + throw new BackendConfigurationCleanupError( "backend configuration reader cleanup could not be confirmed: " + (result.terminationError || "process tree termination was unconfirmed"), ); @@ -354,7 +354,8 @@ export async function loadBackends(options = {}) { try { return await readBackendConfiguration(file, options); } catch (error) { - if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError) { + if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError || + error instanceof BackendConfigurationCleanupError) { throw error; } throw new Error("cannot load explicit backend configuration " + file + ": " + error.message); @@ -364,7 +365,8 @@ export async function loadBackends(options = {}) { try { return await readBackendConfiguration(bundled, options); } catch (error) { - if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError) { + if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError || + error instanceof BackendConfigurationCleanupError) { throw error; } return FALLBACK_BACKENDS; @@ -1294,6 +1296,7 @@ function snapshotFailure(label, result) { 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); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index 3e32907..d960776 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -1322,7 +1322,6 @@ test("backend configuration cleanup uncertainty outranks cancellation", async (c stall: process.env.CLI_AGENT_BRIDGE_TEST_BACKEND_CONFIG_READ_STALL_FILE, }; process.env.NODE_ENV = "test"; - process.env.CLI_AGENT_BRIDGE_BACKENDS = config; 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; @@ -1332,22 +1331,27 @@ test("backend configuration cleanup uncertainty outranks cancellation", async (c 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; }); - 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); + 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) => { From b2f78116b0de3efffbe9bc97bbe58fa1fd6ef1e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Tue, 18 Aug 2026 01:32:28 +0800 Subject: [PATCH 39/40] fix review safety and cancellation gaps --- plugins/Hylouis233/cli-agent-bridge/README.md | 17 +- .../Hylouis233/cli-agent-bridge/server.mjs | 198 +++++++---- .../cli-agent-bridge/tests/fake-backend.mjs | 17 + .../cli-agent-bridge/tests/server.test.mjs | 313 +++++++++++++++++- .../tests/workspace-lock.test.mjs | 25 +- .../cli-agent-bridge/workspace-lock.mjs | 70 ++-- 6 files changed, 538 insertions(+), 102 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index d0638b0..9843c2b 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -143,9 +143,10 @@ you already obtained a valid ID from that backend outside this Plugin. - 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. Periodic ownership checks read that ref - without manufacturing new heartbeat blobs; any extant `starting`/`running` ref remains a - conservative attribution-overlap signal regardless of its last state-transition timestamp. + 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. @@ -195,8 +196,9 @@ you already obtained a valid ID from that backend outside this Plugin. 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 successful fetch/pull operations in - the canonical workspace; when one occurred, the response keeps the worktree/ref snapshot but marks + 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 @@ -207,6 +209,9 @@ you already obtained a valid ID from that backend outside this Plugin. 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 @@ -224,7 +229,7 @@ operator approval rename, interruptible lease state updates, shared quarantine m 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, unborn HEAD and non-HEAD ref changes, checkout-only HEAD moves, +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, diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index adf5bd9..0ed4c21 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -47,6 +47,8 @@ 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" @@ -1342,10 +1344,10 @@ export function backendGitProvenanceEnvironment(tracePath, baseEnvironment = pro delete env[name]; } } - // Trace2 identifies successful fetch/pull commands in the canonical target - // repository. Git does not expose a complete, cross-version per-fetch tip - // log, so their presence makes commit attribution explicitly unavailable - // instead of relying on the last (overwritable) FETCH_HEAD contents. + // 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; } @@ -1555,7 +1557,11 @@ export async function readBackendGitProvenance(provenance, worktreeRoot, options uncertain = true; continue; } - if (session.worktree === targetWorktree && session.exitCode === 0) { + // 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; } @@ -1636,14 +1642,65 @@ export async function runGitCommand(args, { return result; } -// A starting/running lease ref is conservatively active until its exact owner -// CAS removes or transitions it. Periodic ownership probes intentionally avoid -// writing heartbeat blobs, and a stale timestamp cannot prove that an escaped -// worker (or a worker on another host sharing the repository) has stopped. +// 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], @@ -1751,46 +1808,21 @@ async function gitSnapshot(worktreeRoot, options = {}) { throw new Error("git snapshot unreliable: cannot read FETCH_HEAD: " + error.message); } } - const lockRefs = []; - if (lockStoreRoot) { - const storedRefs = await runGitCommand([ - "for-each-ref", "--format=%(refname)%09%(objectname)", WORKSPACE_LOCK_REF_PREFIX, - ], { cwd: lockStoreRoot, ...options }); - const storedRefsFailure = snapshotFailure("git for-each-ref workspace lock store", storedRefs); - if (storedRefsFailure) throw new Error("git snapshot unreliable: " + storedRefsFailure); - for (const line of String(storedRefs.stdout ?? "").split(/\r?\n/u)) { - if (!line) continue; - const separator = line.indexOf("\t"); - if (separator <= 0) continue; - const ref = line.slice(0, separator); - if (ref !== ownLockRef) lockRefs.push(line.slice(separator + 1)); - } - } + 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 signals: leases that are active right now, and the - // persistent run-history records completed delegations leave behind, whose - // [acquiredAt, endedAt] window is checked against this snapshot's window. - const windowStart = Number.isFinite(options.concurrencyWindowStart) - ? options.concurrencyWindowStart - : Number.POSITIVE_INFINITY; - let concurrentDelegations = 0; - for (const oid of lockRefs) { - const blob = await runGitCommand(["cat-file", "blob", oid], { cwd: lockStoreRoot, ...options }); - if (blob.exitCode !== 0) continue; // unreadable owner blob: ignore for disclosure - try { - const record = JSON.parse(blob.stdout); - if (Number.isFinite(record?.endedAt)) { - const acquiredAt = Number.isFinite(record.acquiredAt) ? record.acquiredAt : record.endedAt; - if (acquiredAt <= Date.now() && record.endedAt >= windowStart) { - concurrentDelegations += 1; - } - continue; + // 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 active = record && - (record.workerState === "starting" || record.workerState === "running"); - if (active) concurrentDelegations += 1; - } catch { /* malformed owner blob: ignore for disclosure */ } + } } const snapshot = { // The leading space in porcelain's first XY column is significant (for @@ -1804,6 +1836,7 @@ async function gitSnapshot(worktreeRoot, options = {}) { fetchHeads, concurrentDelegations, }; + Object.defineProperty(snapshot, LOCK_HISTORY_REFS, { value: lockActivity.historyRefs }); return snapshot; } @@ -2127,10 +2160,25 @@ export async function committedDelta(worktreeRoot, before, after, options = {}) } export function backendEntryFromProbe(name, spec, check) { - const available = check.exitCode === 0 && check.treeTerminated === true; - const probeError = check.treeTerminated !== true - ? (check.terminationError || "backend version probe process tree could not be confirmed terminated") - : (check.errorMessage || "command not found or not executable"); + 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, @@ -2145,6 +2193,7 @@ export function backendEntryFromProbe(name, spec, check) { } async function listBackends(cancel = null) { + if (cancel?.cancelled) throw new OperationCancelledError("list_backends cancelled by client"); if (!supportsReliableProcessContainment()) { return [{ name: "unsupported-platform", @@ -2162,20 +2211,18 @@ async function listBackends(cancel = null) { try { backends = await loadBackends({ cancel }); } catch (error) { - if (error instanceof OperationCancelledError) return []; 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) break; + 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) { - if (error instanceof OperationCancelledError) break; throw error; } if (!resolvedCommand) { @@ -2201,6 +2248,13 @@ async function listBackends(cancel = null) { }, }); 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; @@ -2818,9 +2872,6 @@ async function delegateTask(rawArgs, cancel) { return gitProcessQuarantine; }; const allowDirty = rawArgs.allowDirty === true; - // Attribution window for concurrency disclosure: everything between the - // before-snapshot and the after-snapshot. - const attributionWindowStart = Date.now(); let before; try { before = await gitSnapshot(worktreeRoot, { @@ -3104,7 +3155,7 @@ async function delegateTask(rawArgs, cancel) { deadline, ownLockRef: workspaceLease.ref, lockStoreRoot, - concurrencyWindowStart: attributionWindowStart, + concurrencyHistoryBaseline: before[LOCK_HISTORY_REFS], onUnconfirmedProcessTree: quarantineGitProcessTree, }); Object.defineProperty(after, FETCH_PROVENANCE_TIPS, { value: fetchProvenanceTips }); @@ -3323,6 +3374,7 @@ function installShutdownHandlers(stdin, stdout = process.stdout) { process.once("exit", () => { for (const { cancel } of activeRequests.values()) cancel.cancel(); }); + return shutdown; } async function handleMessage(message) { @@ -3376,6 +3428,11 @@ async function handleMessage(message) { 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(); } @@ -3536,7 +3593,11 @@ async function handleMessage(message) { } } -function startStdioServer({ stdin = process.stdin, stdout = process.stdout } = {}) { +export function startStdioServer({ + stdin = process.stdin, + stdout = process.stdout, + onOversizedLine = () => stdin.destroy?.(), +} = {}) { stdin.setEncoding("utf8"); let buffer = ""; stdin.on("data", (chunk) => { @@ -3548,6 +3609,14 @@ function startStdioServer({ stdin = process.stdin, stdout = process.stdout } = { 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"); @@ -3565,10 +3634,19 @@ function startStdioServer({ stdin = process.stdin, stdout = process.stdout } = { 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)) { - startStdioServer(); - installShutdownHandlers(process.stdin, process.stdout); + const shutdown = installShutdownHandlers(process.stdin, process.stdout); + startStdioServer({ + onOversizedLine: () => shutdown(1), + }); } diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs index 566beb6..b9d6f2b 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/fake-backend.mjs @@ -113,6 +113,23 @@ if (spec.branchRoundTrip) { 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"); diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index d960776..ef87ee5 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -4,6 +4,7 @@ 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"; @@ -22,7 +23,8 @@ import { backendEntryFromProbe, backendGitProvenanceEnvironment, closestExistingBase, committedDelta, gitCommonDirectory, gitWorktreeRoot, loadBackends, markWorkspaceQuarantined, - populateCommitishCache, readBackendGitProvenance, readWorkspaceQuarantine, runCommand, runGitCommand, + populateCommitishCache, readBackendGitProvenance, readRepositoryLockActivity, + readWorkspaceQuarantine, runCommand, runGitCommand, startStdioServer, } from "../server.mjs"; const execFileAsync = promisify(execFile); @@ -266,6 +268,33 @@ function unconfirmedGitResult(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"], { @@ -1396,7 +1425,9 @@ test("backend configuration reads obey cancellation, deadline, and shutdown", as client.notify("notifications/cancelled", { requestId: 50_102 }); const cancelled = await listing; assert.ok(Date.now() - cancelledAt < 1_500, "configuration cancellation must return promptly"); - assert.deepEqual(cancelled.result.structuredContent.backends, []); + 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", { @@ -1461,7 +1492,8 @@ test("Windows config reader termination closes real named-pipe I/O", { const cancelledAt = Date.now(); cancelledClient.notify("notifications/cancelled", { requestId: 50_201 }); const cancelled = await listing; - assert.deepEqual(cancelled.result.structuredContent.backends, []); + 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"); @@ -1577,6 +1609,40 @@ test("list_backends rejects a successful probe whose tree cleanup is unconfirmed 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"); @@ -1884,7 +1950,7 @@ test("the private lock store inherits the repository sharing mode", async (conte } }); -test("stale starting and running lease refs remain visible to attribution", async (context) => { +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 }, @@ -1923,8 +1989,62 @@ test("stale starting and running lease refs remain visible to attribution", asyn const idle = await client.request("tools/call", { name: "workspace_status", arguments: { workspacePath: workspace }, }); - assert.equal(idle.result.structuredContent.git.concurrentDelegations, 0, - "a stale idle owner is not an active delegation"); + 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) => { @@ -3144,6 +3264,77 @@ test("workspace_status can be cancelled while queued for the workspace lock", as 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"); @@ -3891,6 +4082,68 @@ test("successive fetches are detected even after FETCH_HEAD is overwritten", asy } }); +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"); @@ -4043,14 +4296,48 @@ test("list_backends can be cancelled while a version probe hangs", async (contex 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"); - await writeFile(hangScript, "setTimeout(() => {}, 60_000);\n"); + 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: process.execPath, - buildArgs: [hangScript, ""], + command: hangingCommand, + buildArgs: [""], resumeArgs: null, experimental: false, }, @@ -4061,12 +4348,16 @@ test("list_backends can be cancelled while a version probe hangs", async (contex context.after(async () => { await client.close(); }); const started = Date.now(); const responsePromise = client.request("tools/call", { name: "list_backends", arguments: {} }, 4242); - await new Promise((resolve) => setTimeout(resolve, 200)); + 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.ok(Array.isArray(response.result.structuredContent.backends)); + 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", { diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs index 3d5b5ee..f8c2220 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs @@ -10,6 +10,7 @@ import { acquireGitWorkspaceLock, localHostIdentity, tryAcquireGitWorkspaceLock, + workspaceHistoryRef, workspaceLockRef, workspaceRecoveryRef, WorkspaceLockCancelledError, @@ -503,11 +504,31 @@ test("periodic ownership probes do not create heartbeat blobs", async (context) 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, 1, - "read-only heartbeat probes must leave only the current owner blob"); + 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; diff --git a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs index 16ba70b..910b910 100644 --- a/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/workspace-lock.mjs @@ -39,31 +39,45 @@ export function workspaceLockRef(key) { return WORKSPACE_LOCK_REF_PREFIX + createHash("sha256").update(key).digest("hex"); } -// A completed delegation leaves a persistent run-window record under this ref -// so later snapshots in the same repository can prove their attribution window -// overlapped another worker, even after that worker's lease ref was deleted. +// 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, owner) { - try { - const record = { - version: 1, - acquiredAt: Number.isFinite(owner.acquiredAt) ? owner.acquiredAt : null, - endedAt: Date.now(), - hostIdentity: owner.hostIdentity, - }; - const oidResult = await runGit(cwd, ["hash-object", "-w", "--stdin"], { - stdinText: JSON.stringify(record) + "\n", - }); - const oid = oidResult.stdout.trim(); - if (oidResult.exitCode !== 0 || !/^[0-9a-f]{40,64}$/u.test(oid)) return; - await runGit(cwd, ["update-ref", "--no-deref", lockRef + WORKSPACE_HISTORY_REF_SUFFIX, oid]); - } catch { - // Best effort: a missing history record only weakens concurrency - // disclosure, never correctness of locking. +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) { @@ -663,7 +677,6 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { } catch { /* the exact candidate set remains sufficient */ } } let deleted = false; - let deletedOwner = currentOwner; const removalErrors = []; for (const [candidateOid, candidateOwner] of candidateOwners) { try { @@ -672,7 +685,6 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { ); if (result.deleted) { deleted = true; - deletedOwner = candidateOwner; break; } } catch (error) { @@ -695,7 +707,6 @@ function createLease({ cwd, ref, oid, owner, heartbeatMs }) { : new AggregateError(removalErrors, "cannot reconcile interrupted workspace lock state"); } released = true; - if (deleted) await writeRunHistory(cwd, ref, deletedOwner); await maintainLockStore(cwd); if (lostError) throw lostError; if (!deleted) throw new Error("workspace lock ownership changed before release"); @@ -783,6 +794,19 @@ export async function tryAcquireGitWorkspaceLock({ 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); From 8f87ae45ad2b8e2c3a33da7f29e59b42045e14c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Tue, 18 Aug 2026 13:09:25 +0800 Subject: [PATCH 40/40] fix(cli-agent-bridge): harden Git snapshot handling --- .../cli-agent-bridge/process-tree-runner.mjs | 11 +- .../Hylouis233/cli-agent-bridge/server.mjs | 75 +++++++++--- .../cli-agent-bridge/tests/server.test.mjs | 107 ++++++++++++++++-- 3 files changed, 166 insertions(+), 27 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree-runner.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree-runner.mjs index 86cffaf..3d8d914 100644 --- a/plugins/Hylouis233/cli-agent-bridge/process-tree-runner.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree-runner.mjs @@ -93,7 +93,12 @@ function parseStandardNpmCmdShim(commandFile) { : shimDirectory; const relativeEntry = path.relative(allowedRoot, entry); if (!relativeEntry || relativeEntry.startsWith("..") || path.isAbsolute(relativeEntry)) return null; - return isFile(entry) ? entry : null; + if (!isFile(entry)) return null; + const adjacentNode = path.join(shimDirectory, "node.exe"); + return { + entry, + nodeExecutable: isFile(adjacentNode) ? adjacentNode : process.execPath, + }; } function monitorWorker(worker) { @@ -135,10 +140,10 @@ function launchWindowsCmd(commandFile, payload) { } } -function launchWindowsNpmShim(entry, payload) { +function launchWindowsNpmShim(shim, payload) { let worker; try { - worker = spawn(process.execPath, [entry, ...payload.args], { + worker = spawn(shim.nodeExecutable, [shim.entry, ...payload.args], { cwd: process.cwd(), env: workerEnvironment(payload), windowsHide: true, stdio: [payload.stdinText === undefined ? "ignore" : "pipe", "inherit", "inherit"], }); diff --git a/plugins/Hylouis233/cli-agent-bridge/server.mjs b/plugins/Hylouis233/cli-agent-bridge/server.mjs index 0ed4c21..fdda0ce 100644 --- a/plugins/Hylouis233/cli-agent-bridge/server.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/server.mjs @@ -8,6 +8,7 @@ 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"; @@ -881,7 +882,7 @@ function resolveTrustedGitExecutable(options = {}) { async function validateWorkspace(workspacePath, options = {}) { if (typeof workspacePath !== "string" || !workspacePath.trim()) { - throw new Error("workspacePath must be a non-empty string"); + throw new InvalidArgumentsError("workspacePath must be a non-empty string"); } const resolved = path.resolve(workspacePath); let stats; @@ -897,9 +898,11 @@ async function validateWorkspace(workspacePath, options = {}) { stats = await interruptibleFilesystemOperation(stat(resolved), options); } catch (error) { if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError) throw error; - throw new Error("workspacePath does not exist: " + resolved); + throw new InvalidArgumentsError("workspacePath does not exist: " + resolved); + } + if (!stats.isDirectory()) { + throw new InvalidArgumentsError("workspacePath must be a directory: " + resolved); } - if (!stats.isDirectory()) throw new Error("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 @@ -907,7 +910,7 @@ async function validateWorkspace(workspacePath, options = {}) { return await interruptibleFilesystemOperation(realpath(resolved), options); } catch (error) { if (error instanceof OperationCancelledError || error instanceof DeadlineExceededError) throw error; - throw new Error("cannot canonicalize workspacePath: " + error.message); + throw new InvalidArgumentsError("cannot canonicalize workspacePath: " + error.message); } } @@ -960,8 +963,8 @@ export async function gitCommonDirectory(workspacePath, options = {}) { } } -function repositoryLockKey(gitCommonDir, repositoryId = null) { - if (process.platform === "linux" && repositoryId) { +export function repositoryLockKey(gitCommonDir, repositoryId = null) { + if (repositoryId) { return "git-common-dir-id:" + repositoryId; } const normalized = path.normalize(gitCommonDir); @@ -1450,7 +1453,11 @@ function sameTraceSnapshot(left, right) { } async function readBoundedTrace(handle, options = {}) { - const captured = Buffer.allocUnsafe(MAX_CAPTURE_CHARS + 1); + 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); @@ -1461,12 +1468,49 @@ async function readBoundedTrace(handle, options = {}) { if (bytesRead === 0) break; offset += bytesRead; } - if (offset > MAX_CAPTURE_CHARS) { - throw new Error("Git fetch provenance trace exceeded the capture limit"); + 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) { @@ -1502,8 +1546,7 @@ export async function readBackendGitProvenance(provenance, worktreeRoot, options } const sessions = new Map(); const normalizeWorktree = (value) => { - const normalized = path.resolve(String(value ?? "")); - return process.platform === "win32" ? normalized.toLowerCase() : normalized; + return path.resolve(String(value ?? "")); }; const targetWorktree = normalizeWorktree(worktreeRoot); let offset = 0; @@ -1793,7 +1836,7 @@ async function gitSnapshot(worktreeRoot, options = {}) { worktreeRoot, String(out.fetchHeadPath ?? "").replace(/\r?\n$/u, ""), ); try { - const rawFetchHead = await interruptibleFilesystemOperation(readFile(fetchHeadPath, "utf8"), options); + 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]; @@ -2753,15 +2796,17 @@ async function delegateTask(rawArgs, cancel) { throw error; } if (!rawArgs || typeof rawArgs.backend !== "string" || !rawArgs.backend.trim()) { - throw new Error("backend must be a non-empty string"); + 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 Error("unknown backend \"" + backend + "\"; use list_backends to see configured backends"); + throw new InvalidArgumentsError( + "unknown backend \"" + backend + "\"; use list_backends to see configured backends", + ); } if (typeof rawArgs.task !== "string" || !rawArgs.task.trim()) { - throw new Error("task must be a non-empty string"); + throw new InvalidArgumentsError("task must be a non-empty string"); } let backendCommand; try { diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs index ef87ee5..34b9960 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs @@ -23,8 +23,9 @@ import { backendEntryFromProbe, backendGitProvenanceEnvironment, closestExistingBase, committedDelta, gitCommonDirectory, gitWorktreeRoot, loadBackends, markWorkspaceQuarantined, - populateCommitishCache, readBackendGitProvenance, readRepositoryLockActivity, - readWorkspaceQuarantine, runCommand, runGitCommand, startStdioServer, + populateCommitishCache, readBackendGitProvenance, readBoundedRegularFile, + readRepositoryLockActivity, readWorkspaceQuarantine, repositoryLockKey, + runCommand, runGitCommand, startStdioServer, } from "../server.mjs"; const execFileAsync = promisify(execFile); @@ -876,6 +877,19 @@ test("Windows Job runner contains launch and preserves backend streams and exit 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, }); @@ -1020,13 +1034,10 @@ async function repositoryIdFromStore(store) { } async function repositoryKey(canonicalGitCommonDir) { - if (process.platform === "linux") { - const repositoryId = await repositoryIdFromStore(path.join( - canonicalGitCommonDir, "cli-agent-bridge-lock-store.git", - )); - return "git-common-dir-id:" + repositoryId; - } - return "git-common-dir:" + path.normalize(canonicalGitCommonDir); + const repositoryId = await repositoryIdFromStore(path.join( + canonicalGitCommonDir, "cli-agent-bridge-lock-store.git", + )); + return "git-common-dir-id:" + repositoryId; } async function repositoryStatePaths(canonicalGitCommonDir) { @@ -1549,6 +1560,24 @@ test("explicit invalid delegation timeouts are rejected before side effects", as 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"); @@ -3776,6 +3805,14 @@ test("repository renames cannot create a second cross-process lease", { } }); +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); @@ -3895,6 +3932,58 @@ test("provenance reads stay bound to the originally opened regular file", async ); }); +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 () => {