From 75049d5bfcdf11dd84b4a92ae8831cfea6c6c91a Mon Sep 17 00:00:00 2001 From: ajhcs <176340565+ajhcs@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:59:50 +0000 Subject: [PATCH 1/2] Release hardened Co-Engineer and Cursor control planes --- .agents/plugins/marketplace.json | 32 + .gitignore | 3 + CHANGELOG.md | 101 +- CONTRIBUTING.md | 29 +- README.md | 156 +- docs/control-plane-reliability-plan.md | 44 +- docs/data-handling.md | 8 +- docs/preflight-inspector.md | 100 +- docs/provider-capability-map.md | 42 +- docs/target-contract.md | 16 +- examples/preflight-result.json | 17 +- examples/target-context.json | 4 +- .../.codex-plugin/plugin.json | 4 +- plugins/cursor-cloud-control/.mcp.json | 1 + plugins/cursor-cloud-control/LICENSE | 21 + plugins/cursor-cloud-control/README.md | 163 +- plugins/cursor-cloud-control/mcp/client.mjs | 87 +- plugins/cursor-cloud-control/mcp/ledger.mjs | 431 ++++- plugins/cursor-cloud-control/mcp/local.mjs | 1238 ++++++++++++--- plugins/cursor-cloud-control/mcp/server.mjs | 844 +++++++++- .../cursor-cloud-control/mcp/validation.mjs | 73 +- plugins/cursor-cloud-control/package.json | 6 +- .../control-cursor-cloud-agents/SKILL.md | 41 +- .../skills/control-cursor-local-cli/SKILL.md | 114 +- .../cursor-cloud-control/test/client.test.mjs | 29 +- .../cursor-cloud-control/test/ledger.test.mjs | 153 +- .../test/lifecycle.test.mjs | 100 +- .../cursor-cloud-control/test/local.test.mjs | 479 +++++- .../cursor-cloud-control/test/server.test.mjs | 576 ++++++- .../test/validation.test.mjs | 31 + .../.codex-plugin/plugin.json | 2 +- plugins/plumbob-harness-control/LICENSE | 21 + plugins/plumbob-harness-control/README.md | 215 ++- .../plumbob-harness-control/mcp/control.mjs | 1396 ++++++++++++++++- .../plumbob-harness-control/mcp/daemon.mjs | 24 +- .../mcp/grok-build.mjs | 113 ++ .../mcp/grok-outer-sandbox.mjs | 4 +- .../plumbob-harness-control/mcp/preflight.mjs | 2 +- .../plumbob-harness-control/mcp/runner.mjs | 70 + .../plumbob-harness-control/mcp/secrets.mjs | 105 +- .../plumbob-harness-control/mcp/server.mjs | 88 +- plugins/plumbob-harness-control/mcp/store.mjs | 18 + plugins/plumbob-harness-control/package.json | 3 +- .../skills/control-plumbob-agents/SKILL.md | 28 +- .../test/acp-event-ledger.test.mjs | 2 +- .../test/branding.test.mjs | 2 +- .../test/control.test.mjs | 407 +++++ .../test/grok-build.test.mjs | 19 + .../test/grok-outer-sandbox.test.mjs | 12 +- .../test/runner.test.mjs | 18 +- .../test/secrets.test.mjs | 90 ++ .../test/server.test.mjs | 8 +- .../test/state.test.mjs | 81 +- scripts/inspector-preflight.mjs | 10 + scripts/plugin-activation-fixture.mjs | 33 + scripts/validate-release.mjs | 115 +- 56 files changed, 7100 insertions(+), 729 deletions(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 plugins/cursor-cloud-control/LICENSE create mode 100644 plugins/plumbob-harness-control/LICENSE create mode 100644 plugins/plumbob-harness-control/test/secrets.test.mjs diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..d7ef615 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,32 @@ +{ + "name": "codex-co-engineer", + "interface": { + "displayName": "Codex-Co-Engineer" + }, + "plugins": [ + { + "name": "plumbob-harness-control", + "source": { + "source": "local", + "path": "./plugins/plumbob-harness-control" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + }, + { + "name": "cursor-cloud-control", + "source": { + "source": "local", + "path": "./plugins/cursor-cloud-control" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.gitignore b/.gitignore index becf525..1fd8d77 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ docs/research/ .codex/* !.codex/release-gate.toml .agents/ +!.agents/ +!.agents/plugins/ +!.agents/plugins/marketplace.json .serena/ .claude/ .cursor/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ee70c90..bf7dbe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,26 +2,38 @@ All notable public changes to Codex-Co-Engineer are recorded here. -## [1.0.0] - 2026-08-16 +## [Unreleased] + +No unreleased changes. + +## [2.2.0] - 2026-08-19 ### Added -- Public Codex-Co-Engineer release surface centered on DeepSeek Harness. -- Stable `plumbob-harness-control` compatibility identifier retained. -- Target, configuration-digest, fingerprint, MCP Inspector, lifecycle, and - data-handling release contracts. -- Public configuration and preflight examples without credentials or personal - filesystem paths. -- Release inventory validation and GitHub CI guidance. +- Control-plane target binding can resolve and attest the selected local or + staged target while preserving the strict path, Git identity, and postflight + contract. +- Terminal Co-Engineer jobs expose a bounded final response alongside their + lifecycle receipt, so callers do not need to parse the complete provider log + to retrieve the result. +- Cursor Cloud Control `0.4.0` adds explicit reconciliation for uncertain + creates and keeps provider-assigned IDs distinct from local reservations. +- Cursor Local Control is exposed as wire identity `0.2.0` with an explicit + administrator opt-in for host-trusted direct-CLI runs; status remains the + default catalog surface and Cloud/local state stays separate. ### Changed -- User-facing plugin branding is now Codex-Co-Engineer. -- Package metadata is public and versioned `1.0.0`. -- Personal Prime Lab, generated runtime, state, and credential paths are - explicitly outside the public release boundary. +- Co-Engineer runtime and final-response handling now fail closed when target + binding or durable completion cannot be confirmed. +- Cursor Cloud reconciliation retries only bounded provider-absence checks and + never resubmits an uncertain mutation; definitive conflicts and rate limits + remain failed provider responses. +- Release validation, activation fixtures, Inspector examples, and package + inventories identify the current Co-Engineer `2.2.0` and Cursor `0.4.0` + surfaces without changing the independently pinned ACPX runtime. -## [Unreleased] +## [2.1.2] - 2026-08-18 ### Added @@ -38,14 +50,12 @@ All notable public changes to Codex-Co-Engineer are recorded here. deliberate ACP, worktree, prompt-file, and system-prompt-override omissions. - Cursor Cloud Control plugin with typed Cursor Cloud Agents API v1 lifecycle, bounded SSE/polling, usage, artifact, and archive/delete operations. -- Packaged (but not production-exposed) Cursor Local Control foundation for the - locally installed Cursor Agent CLI, with separate owner-only state and - receipts, explicit read-only and isolated-worktree policies, bounded NDJSON - logs, and owned cancellation for later host acceptance. Only local - status/auth/permissions diagnostics are ready for use; run dispatch remains - fail-closed and unwired pending real Cursor plus Bubblewrap acceptance. The - adapter never accepts Cloud IDs or shares Cloud credentials, state, or - receipts. +- Cursor Local Control with an administrator-activated, + `execution_profile: "host_trusted"` direct Cursor CLI surface. The public + default remains status/auth/permissions only; host-trusted reads use Ask + mode, explicit implement calls use `--force` and an isolated worktree, and + receipts identify process-user authority with no outer sandbox claim. Local + state, credentials, IDs, and receipts remain separate from Cursor Cloud. - Owner-only credential handling, durable mutation ledger, redacted receipts, and artifact path/overwrite protections. - Cursor MCP preflight, plugin validation, unit coverage, and package inventory @@ -82,13 +92,14 @@ All notable public changes to Codex-Co-Engineer are recorded here. Grok's kind-specific HOME guard. It uses Grok's noninteractive `auto` permission mode for implement jobs and fails closed when an implement run exits without an allowed workspace change. -- Cursor Cloud Control `0.3.0` packages the distinct local Cursor CLI - foundation but keeps it unwired and not exposed in the production catalog; - only status/auth/permissions diagnostics are ready pending real Cursor plus - Bubblewrap host acceptance. Its cloud half gives repository discovery and - repository-backed creation one bounded 60-second attempt, never retries the strictly - rate-limited inventory endpoint, and degrades discovery timeouts into an - explicit unavailable result. +- Cursor Cloud Control `0.3.0` packages the distinct local Cursor CLI surface; + its public default catalog remains status/auth/permissions only, while an + administrator may explicitly activate the host-trusted direct-CLI profile. + The retained Bubblewrap foundation remains separate and unwired, and each + host-trusted installation still requires real Cursor process acceptance. + Its cloud half gives repository discovery and repository-backed creation one + bounded 60-second attempt, never retries the strictly rate-limited inventory + endpoint, and degrades discovery timeouts into an explicit unavailable result. - DeepSeek Harness is invoked directly in the attested target checkout and is validated independently through its own CLI version. - DeepSeek headless and web jobs use a managed absolute DSH profile/state root, @@ -101,19 +112,39 @@ All notable public changes to Codex-Co-Engineer are recorded here. are explicit, selector-aware, compactly cached, and stale-on-refresh-failure. - Grok ACP is limited to read-only capacity telemetry; coding dispatch remains on the direct headless CLI interface. -- Configured provider credentials are standing authorization for task-scoped - calls; no per-job egress prompt is added. Grok and DSH harness-internal - subagents can be requested, while receipts keep actual effectiveness - `unknown` unless provider evidence proves delegation occurred. +- Configured provider credentials or sessions are reused as standing + authorization for task-scoped calls; normal provider expiry or revocation + can still require reauthentication, and no per-job egress prompt is added. + Grok and DSH harness-internal subagents can be requested, while receipts keep + actual effectiveness `unknown` unless provider evidence proves delegation + occurred. - Cursor model discovery is dynamic, custom subagents remain typed and bounded, identity responses omit personal fields, and write-mode repository dispatch requires an immutable starting commit. ### Removed -- All Prime Intellect integrations, including Prime Agent, Prime Eval, Prime - CLI compatibility probes, lab diagnostics, environment variables, schemas, - runner parsing, tests, and runtime patch generation. +- Legacy provider integrations and compatibility surfaces that are not part of + the public control-plane release, including their private runtime hooks. + +## [1.0.0] - 2026-08-16 + +### Added + +- Public Codex-Co-Engineer release surface centered on DeepSeek Harness. +- Stable `plumbob-harness-control` compatibility identifier retained. +- Target, configuration-digest, fingerprint, MCP Inspector, lifecycle, and + data-handling release contracts. +- Public configuration and preflight examples without credentials or personal + filesystem paths. +- Release inventory validation and GitHub CI guidance. + +### Changed + +- User-facing plugin branding is now Codex-Co-Engineer. +- Package metadata is public and versioned `1.0.0`. +- Generated runtime, state, and credential paths remain outside the public + release boundary. Future changes should document protocol, target-contract, lifecycle, and compatibility effects before implementation details. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b06c817..5650e6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,10 +6,9 @@ focused on the Codex control plane and the DeepSeek Harness integration. ## Before opening a pull request ```bash -node --version # Node 24 or newer -cd plugins/plumbob-harness-control -npm test -cd ../.. +node --version # Node 24.x for the local gate +npm --prefix plugins/plumbob-harness-control test +npm --prefix plugins/cursor-cloud-control test node scripts/validate-release.mjs git diff --check ``` @@ -19,6 +18,25 @@ processes, temporary Git repositories, and redacted test data. A change that requires an external model should document the manual, opt-in verification separately. +The GitHub Actions workflow runs both plugin suites and the portable fixture, +Inspector, reproducible-build, provenance, and package-inventory checks. It is +a diagnostic mirror, not release authority: GitHub CI does not install the +`release-gate` CLI or prove this host's attested Bubblewrap/cgroup boundary. + +The authoritative gate is `local-exact-tree`. Run it from a dedicated clean +worktree containing exactly the candidate files on Linux with Node major 24, +the pinned MCP Inspector `2.2.0`, executable Bubblewrap, and static BusyBox: + +```bash +release-gate plan --repo "$PWD" +release-gate run --repo "$PWD" +``` + +Review the resulting receipt and package inventories. A green GitHub check +cannot replace that local receipt. The gate is provider-free except for its +bounded ACPX provenance/signature metadata checks; never add provider +credentials to CI. + ## Code and contract expectations - Preserve `plumbob-harness-control` as the stable MCP compatibility ID. @@ -26,7 +44,8 @@ separately. - Require exactly one target for every dispatch; never infer it from prompt prose or silently fall back after an explicit-target error. - Canonicalize target/configuration input before hashing and compare the - caller-supplied fingerprint. + caller-supplied fingerprint, unless the caller explicitly opts into the + control-plane binding path. - Keep absolute deadlines independent from progress heartbeats. - Emit one terminal state and distinguish client, transport, protocol, process-startup, tool, timeout, and cancellation failures. diff --git a/README.md b/README.md index 67300bf..0e8889d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ The stable plugin and MCP identifier is `plumbob-harness-control`. The public product name is **Codex-Co-Engineer**. Keeping the technical identifier stable allows existing Codex configurations to migrate without a server-name break. +Current public release surfaces are Co-Engineer `2.2.0`, Cursor Cloud Control +`0.4.0`, and the separately advertised `cursor-local-control` wire identity +`0.2.0`. ACPX remains independently pinned and is not versioned with these +plugins. + ## Release contents ```text @@ -31,21 +36,104 @@ runtimes. ## Quick start -1. Install Node.js 24 or newer. Runtime packages support Node 24+, while the - reproducible maintainer release gate is intentionally pinned to Node major - 24. -2. Install and configure DeepSeek Harness using its upstream documentation when - using DeepSeek jobs. For Grok Build, install the official CLI and - authenticate it separately (`grok login` or device auth); the MCP server - never automates installation/login or accepts xAI credentials as tool - arguments. -3. Clone this repository and register - `plugins/plumbob-harness-control` as a local Codex plugin. -4. Set the provider credential and runtime workspace in the MCP server - environment. A template is in - [`config/configuration.example.json`](config/configuration.example.json). -5. Run the MCP Inspector preflight for the exact target before dispatching a - job. +### Prerequisites + +The fully supported and release-tested host is Linux with Node.js 24.x. The +runtime packages declare Node `>=24.0.0`; the authoritative local gate pins +Node major 24 and additionally requires executable Bubblewrap, static BusyBox, +and MCP Inspector `2.2.0`. Windows is not a supported host for the managed +POSIX process-group and DSH receipt guarantees. A target checkout also needs +Git and a clean, exact commit. + +For provider-backed work, install the provider CLIs separately and verify them +before opening Codex. These commands refer to the DeepSeek Harness `dsh` and +Grok Build `grok` CLIs, not MCP tool names: + +```bash +node --version # 24.x for the authoritative gate +dsh --version # tested profile: 0.1.0-rc.6 +grok --version # local acceptance: Grok Build 1.0.4 +grok models # read-only auth/readiness probe +``` + +DeepSeek Harness `0.1.0-rc.6` is the accepted DSH adapter version. A +`deepseek_agent` run requires `MODEL_API_KEY` in the MCP process environment or +an owner-only file. Grok Build must be authenticated through its normal CLI +flow (`grok login` or device auth), or receive `XAI_API_KEY` through the MCP +process environment. The plugin never automates login or accepts credentials +as tool arguments. + +For optional local Cursor work, install the official [Cursor Agent CLI](https://cursor.com/docs/cli/reference/authentication) +separately, keep a dedicated `cursor-agent` executable path, and set +`CURSOR_LOCAL_CLI_BIN` when it is not on the MCP process `PATH`. Authenticate +it with `cursor-agent login` (or the administrator-managed local API-key +environment) and verify the account with `cursor-agent status` before opening +Codex. Never put a Cursor key in a tool call or use `--api-key`; the public +local catalog is status-only until an administrator explicitly enables +host-trusted runs. + +### Register and activate in Codex + +Codex registration is marketplace-based. This repository is itself a +marketplace: its root `.agents/plugins/marketplace.json` points at both plugin +packages. Add the public Git marketplace directly (or use the same command +with an absolute local checkout path), then install either or both entries: + +```bash +codex plugin marketplace add ajhcs/Codex-Co-Engineer --ref main +# For a local checkout instead: +# codex plugin marketplace add /absolute/path/to/Codex-Co-Engineer +codex plugin marketplace list --json +codex plugin list --available --json +codex plugin add plumbob-harness-control@codex-co-engineer +codex plugin add cursor-cloud-control@codex-co-engineer +codex plugin list --json +``` + +Do not use the unsupported `codex plugin add ./plugins/...` form. In the Codex +App, the enabled entries come from the same plugin configuration. After +installing or changing a plugin, fully restart the App and start a fresh task +before expecting its MCP tools or skills in the callable catalog. +`codex plugin list --json` verifies installation and enabled state; it does not +refresh an already-running task. + +### Configure and make the first call + +Set the provider credential and runtime workspace in the MCP server environment +before the fresh task starts. A template is in +[`config/configuration.example.json`](config/configuration.example.json). +Then use this bounded sequence in the fresh Codex task: + +1. Call the Co-Engineer MCP `status` tool with `{}`. It is provider-free unless + `diagnostics: true` is explicitly requested. +2. Call the Co-Engineer MCP `preflight` tool with `schema_version: "codex-co-engineer.config.v1"`, + `kind: "preflight"`, `target_binding: "control_plane"`, and one exact + `target_context`. For a local checkout, use `mode: "explicit"` with its + absolute `working_directory`, `expected_git_root`, current 40-character + `expected_head`, `allowed_paths`, and `role: "review"` or `"verify"`. + `target_binding` lets the connector compute the fingerprint; it does not + remove the exact-path, HEAD, or postflight checks. +3. Call the Co-Engineer MCP `run` tool with the same target context, a stable `request_id`, and either + `kind: "deepseek_agent"` or `kind: "grok_build"`. Keep the prompt text-only + and use only the typed provider fields in the tool schema. +4. Call the Co-Engineer MCP `jobs` tool with `{"action":"wait","job_id":"","until":"terminal"}`, + then call the same `jobs` tool with `{"action":"get","job_id":""}`. + +For a GitHub review, `target_context.mode: "staged"` with +`source.type: "github"`, an HTTPS `repository`, and an optional `ref` avoids +manual local fingerprint calculation. The connector clones an owner-only, +origin-free checkout and binds its exact commit before dispatch. A private +source requires noninteractive Git credentials already available to the MCP +process (for example, an owner-approved credential helper or askpass/secret +manager integration). Staging sets `GIT_TERMINAL_PROMPT=0`, so an interactive +username/password prompt cannot succeed; keep credentials out of the +repository URL, target context, prompts, and tool arguments. + +See the complete MCP call shapes in the +[`Co-Engineer plugin README`](plugins/plumbob-harness-control/README.md) and +the exact Inspector workflow in +[`docs/preflight-inspector.md`](docs/preflight-inspector.md). User-visible +changes are tracked in [`CHANGELOG.md`](CHANGELOG.md). Example environment (replace placeholders locally; never commit the values): @@ -61,9 +149,12 @@ export CODEX_CO_ENGINEER_STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/codex- `CODEX_CO_ENGINEER_RUNTIME_WORKSPACE` is used only when an explicit target contract selects `mode: "default"`. It is not prompt-derived target authority. A job must carry one strict target contract with an absolute cwd, -expected Git root and HEAD, allowed paths, role, and caller-supplied expected -fingerprint. Prompt-level `cd` is never authoritative, and an invalid -explicit target never falls back to a default workspace. +expected Git root and HEAD, allowed paths, and role. Normal callers should set +`target_binding: "control_plane"` so the connector computes and binds the +resolved identity. Advanced callers that hold fingerprint authority may omit +`target_binding` and supply the caller-computed `expected_target_fingerprint`. +Prompt-level `cd` is never authoritative, and an invalid explicit target never +falls back to a default workspace. For Grok Build, the server invokes the configured `grok` executable directly (`CODEX_CO_ENGINEER_GROK_COMMAND` may select an administrator-approved binary) @@ -77,22 +168,24 @@ not exposed. Bounded typed `json_schema` input is supported for structured JSON output; ACP (`grok agent stdio`) is documented but intentionally deferred until it can preserve the same target and lifecycle guarantees. -Configured Grok, DSH, and Cursor credentials are standing authorization for -task-scoped provider work; the control planes do not add a per-job data-egress -prompt. Repository writes, destructive Git operations, deployments, and PR -creation retain their normal task authority and safety controls. +Configured Grok, DSH, and Cursor credentials or provider sessions are reused as +standing authorization for task-scoped provider work. Provider credentials and +sessions can expire or be revoked and may require ordinary provider +reauthentication; the control planes do not add a per-job data-egress prompt. +Repository writes, destructive Git operations, deployments, and PR creation +retain their normal task authority and safety controls. ## Co-Engineer tools The plugin exposes seven stable MCP tools: -- `preflight` attests the target, configuration digest, protocol, and tool set. -- `status` reports DeepSeek, Grok, credential-presence, UI, and recent-job state. -- `capacity` reads compact Codex/Grok capacity and exact DSH job-token evidence. -- `runtime` starts or stops the optional plugin-owned loopback DeepSeek UI. -- `run` dispatches exactly `deepseek_agent` or `grok_build`. -- `jobs` lists, inspects, waits for, or cursor-pages managed jobs. -- `cancel` cancels one exact plugin-owned job. +- Co-Engineer MCP `preflight` attests the target, configuration digest, protocol, and tool set. +- Co-Engineer MCP `status` reports DeepSeek, Grok, credential-presence, UI, and recent-job state. +- Co-Engineer MCP `capacity` reads compact Codex/Grok capacity and exact DSH job-token evidence. +- Co-Engineer MCP `runtime` starts or stops the optional plugin-owned loopback DeepSeek UI. +- Co-Engineer MCP `run` dispatches exactly `deepseek_agent` or `grok_build`. +- Co-Engineer MCP `jobs` lists, inspects, waits for, or cursor-pages managed jobs. +- Co-Engineer MCP `cancel` cancels one exact plugin-owned job. DSH/Muse dollar spend, account quota remaining, and reset time remain `unknown`: the installed harness does not prove them. Experimental ACPX session @@ -100,8 +193,9 @@ transport and the Bubblewrap-based Grok outer runtime are packaged only as gated conformance components. They are not wired to a public MCP `sessions` tool or to direct `grok_build` dispatch in this release. -Every dispatch requires the versioned target contract, caller-supplied target -fingerprint, stable request ID, and bounded timeout. See the +Every dispatch requires the versioned target contract, a stable request ID, and +a bounded timeout; target identity is either caller-asserted or explicitly +bound by the control plane. See the [plugin README](plugins/plumbob-harness-control/README.md#mcp-tool-calls) for the complete call shapes and examples. diff --git a/docs/control-plane-reliability-plan.md b/docs/control-plane-reliability-plan.md index 94ac90c..c9a06a7 100644 --- a/docs/control-plane-reliability-plan.md +++ b/docs/control-plane-reliability-plan.md @@ -1,6 +1,8 @@ # Control-plane reliability and full-feature utilization plan -Status: implementation candidate under release validation +Status: current release baseline (Co-Engineer 2.2.0; Cursor Control 0.4.0; +Cursor Local wire identity 0.2.0). +Host-specific acceptance items remain explicitly called out below. Scope: Codex-Co-Engineer, DeepSeek Harness (DSH), Grok Build, Cursor Cloud and Local Control, Codex-native delegation, and their lifecycle integrations. @@ -25,11 +27,12 @@ can prove support, and Codex-native subagents should be used for independent local work. A provider that cannot report whether delegation was used must say `unknown`, not imply success. -Configured Cursor, Grok, and DSH credentials are standing authorization to use -those providers for task-scoped work. The control plane does not add per-job -egress prompts or approval receipts. Repository writes, destructive Git -operations, production changes, and PR creation retain their ordinary task -authority and safety controls. +Configured Cursor, Grok, and DSH credentials or provider sessions are reused as +standing authorization for task-scoped work. Provider sessions can expire or be +revoked and may require ordinary provider reauthentication; the control plane +does not add per-job egress prompts or approval receipts. Repository writes, +destructive Git operations, production changes, and PR creation retain their +ordinary task authority and safety controls. The implementation should maximize useful provider capability per model-facing tool call. Prefer dynamic provider catalogs, installed profiles, compact presets, @@ -43,10 +46,10 @@ already provides strict target contracts, separate Co-Engineer and Cursor MCP servers, package validation, Inspector preflights, and a repository release gate. -The initial mixed tree contained Co-Engineer 2.0.3 and Cursor 0.1.1 work beside -an unrelated untracked `inline-keys` plugin. This candidate targets -Co-Engineer 2.1.2 and Cursor 0.3.0; its local Cursor wire identity is -independently versioned 0.1.0; `inline-keys` remains outside the release. +The current public baseline is Co-Engineer 2.2.0 and Cursor Control 0.4.0; +the separately advertised Cursor Local wire identity is 0.2.0. +Generated runtime, state, credential, and other host-local paths remain outside +the public release artifacts. Observed baseline behavior: @@ -55,12 +58,12 @@ Observed baseline behavior: sandbox contract (Landlock on Linux, Seatbelt on macOS); host-specific Bubblewrap probes are optional integration checks, not product readiness prerequisites. -- Cursor identity returns the upstream identity object after secret redaction, - so personal name and email fields can still reach model context. -- Co-Engineer diagnostics can report Grok ready while the top-level summary says - it is not ready. -- Co-Engineer status returns full recent-job configurations and lifecycle - histories. +- Cursor identity uses an explicit allowlist projection, so upstream personal + identity fields are not returned by default. +- Co-Engineer promotes the diagnostic Grok authentication result into the + top-level readiness summary so those views cannot disagree. +- Co-Engineer status returns compact recent-job summaries; exact job retrieval + remains available for bounded configuration and lifecycle detail. - Grok review and verify use noninteractive `auto` permission mode so blocked tool calls fail back to the model, while the CLI-managed `read-only` sandbox remains the hard write boundary. @@ -81,13 +84,14 @@ Observed baseline behavior: | Durable broker configuration | Repository + host installer | Define contract, bootstrap, permissions, migration, and tests | | `agentctl` and worktree-bootstrap state defaults | Their component owners | Supply shared contract and linked acceptance tests | | Plugin cache retention and task leases | Codex app/plugin manager | Supply a reproducible fixture and app-visible acceptance test | -| Provider account and repository access | Configured Cursor/Grok/DSH credentials | Treat as standing authorization and report ordinary provider errors | +| Provider account and repository access | Configured Cursor/Grok/DSH credentials or sessions | Reuse as standing authorization; report ordinary expiry, revocation, or provider errors | ## Milestone 0: preserve and classify the candidate -Move the existing dirty candidate to an isolated branch/worktree without -rewriting it. Inventory every hunk and assign it to a release or discard decision. -Keep `inline-keys` separate unless its release dependency is explicitly proven. +Move any dirty candidate to an isolated branch/worktree without rewriting it. +Inventory every hunk and assign it to a release or discard decision. Keep +unrelated local plugins and generated files outside the public release unless +their release dependency is explicitly proven. Acceptance: diff --git a/docs/data-handling.md b/docs/data-handling.md index 3752332..0da0699 100644 --- a/docs/data-handling.md +++ b/docs/data-handling.md @@ -2,9 +2,11 @@ Provider credentials are accepted only from the server environment or a protected file outside the repository. They are never valid tool arguments. -Once configured, those credentials are standing authorization for task-scoped -provider calls; the control planes do not ask for per-job data-egress approval. -Writes, destructive Git, deployments, and PR creation remain separately controlled. +Once configured, those credentials or provider sessions are reused as standing +authorization for task-scoped provider calls. Credentials and sessions can +expire or be revoked and may require ordinary provider reauthentication; the +control planes do not ask for per-job data-egress approval. Writes, destructive +Git, deployments, and PR creation remain separately controlled. For `grok_build`, `MODEL_API_KEY` is not required or passed to the child; Grok's OAuth/session state remains under the user's normal home and an administrator may provide `XAI_API_KEY` through the daemon environment. The diff --git a/docs/preflight-inspector.md b/docs/preflight-inspector.md index 9be63b3..1667cc1 100644 --- a/docs/preflight-inspector.md +++ b/docs/preflight-inspector.md @@ -1,36 +1,100 @@ # MCP Inspector preflight -Use MCP Inspector 2.2 or newer against the stdio server. First calculate the -expected fingerprint from a reviewed target file: +Run MCP Inspector `2.2.0` or newer against the exact stdio server that will be +activated. The repository's canonical provider-free check creates a temporary +clean Git target, uses an owner-only temporary state directory, checks the +advertised schema, and verifies the attestation fields: ```bash -node scripts/target-fingerprint.mjs examples/target-context.json +node scripts/inspector-preflight.mjs ``` -Then call `preflight` with `schema_version`, the same `target_context`, and the -caller-held fingerprint: +The script is the reproducible integration check used by the local gate and +CI. It does not submit a DSH or Grok job. Install the pinned Inspector for a +manual run with: ```bash +npm install --global @modelcontextprotocol/inspector@2.2.0 +``` + +## Manual exact-target call + +For a clean local checkout, fill in the absolute paths and current 40-character +HEAD below. `target_binding: "control_plane"` makes the connector compute and +bind the target fingerprint; omit that field only when supplying the digest +returned by `scripts/target-fingerprint.mjs` yourself. + +```bash +TARGET_ROOT=/absolute/path/to/clean/checkout +TARGET_HEAD="$(git -C "$TARGET_ROOT" rev-parse HEAD)" +TARGET_ARGS="$(TARGET_ROOT="$TARGET_ROOT" TARGET_HEAD="$TARGET_HEAD" node --input-type=module -e ' +const root = process.env.TARGET_ROOT; +const head = process.env.TARGET_HEAD; +process.stdout.write(JSON.stringify({ + schema_version: "codex-co-engineer.config.v1", + kind: "preflight", + target_binding: "control_plane", + target_context: { + schema_version: "codex-co-engineer.target.v1", + mode: "explicit", + working_directory: root, + expected_git_root: root, + expected_head: head, + allowed_paths: ["."], + role: "review" + } +})); +')" + mcp-inspector --cli node plugins/plumbob-harness-control/mcp/server.mjs \ --method tools/call --tool-name preflight \ - --tool-args-json '{"schema_version":"codex-co-engineer.config.v1","kind":"preflight","target_context":{...},"expected_target_fingerprint":"sha256:..."}' \ + --tool-args-json "$TARGET_ARGS" \ --format json ``` -Accept only a result containing the matching `target_fingerprint`, absolute -`resolved_workspace` and `resolved_cwd`, `configuration_digest`, `transport`, -`protocol_version`, `server_identity`, and `available_tools`. The repository -integration fixture runs the same assertion end to end: +Set `TARGET_ROOT` to the clean checkout you intend to review. The command +derives its exact HEAD and emits the JSON argument from those values, so the +target contract remains visible and auditable. It must return a result +containing: + +- `target_fingerprint` +- absolute `resolved_workspace` and `resolved_cwd` +- `configuration_digest` +- `transport` and `protocol_version` +- `server_identity` +- `available_tools`, including `preflight`, `status`, `capacity`, `runtime`, + `run`, `jobs`, and `cancel` + +To use caller-held fingerprint authority instead, create a target contract +with the same exact values and run: ```bash -node scripts/inspector-preflight.mjs +node scripts/target-fingerprint.mjs /absolute/path/to/target-context.json ``` -Preflight remains target/configuration attestation and does not query provider -capacity. A current candidate should advertise the explicit read-only -`capacity` tool in `available_tools`; call it separately when routing needs -Codex, Grok, or DSH usage data. +Pass the resulting `target_fingerprint` as +`expected_target_fingerprint` and leave out `target_binding`. A changed Git +HEAD, path identity, or configuration must produce a new preflight; never +reuse a stale digest. + +For a GitHub review, use `target_context.mode: "staged"` with a GitHub HTTPS +`source` and `target_binding: "control_plane"`; the connector stages an +owner-only, origin-free checkout and attests its resolved commit before a +worker starts. Private sources require a noninteractive Git credential helper +or askpass/secret-manager integration already available to the MCP process; +staging forces `GIT_TERMINAL_PROMPT=0`, and credential-bearing URLs are +rejected. + +## Inspector configuration and interpretation + +When using a saved Inspector session configuration, pass it explicitly with +`--config /absolute/path/to/config.json`. That file is read-only and must name +the intended server; do not let Inspector's writable default catalog select a +workspace for release automation. The direct `--cli node ...` form above +starts the checked-out server and avoids relying on a previously selected +catalog entry. -Inspector configuration files should be passed with `--config`; a missing or -malformed explicit config must fail. Do not permit Inspector's writable -default catalog to select a workspace for release automation. +Generic `preflight` is target/configuration attestation, not a provider-capacity +query. Use the separate Co-Engineer `capacity` tool for explicit Codex, Grok, +or DSH usage data. Use `status({"diagnostics":true})` only for the bounded, +read-only Grok `models` authentication probe. diff --git a/docs/provider-capability-map.md b/docs/provider-capability-map.md index 8db357c..7d853a8 100644 --- a/docs/provider-capability-map.md +++ b/docs/provider-capability-map.md @@ -207,25 +207,29 @@ Sources: The `cursor-local-control` MCP server is a separately packaged, typed adapter for the administrator-installed Cursor Agent CLI on Plumbob. Its foundation -retains three contracts (`status`, `run`, and `runs`), but the shipped wire -catalog exposes only `status` (with local/auth/permissions actions): provider -dispatch and process lifecycle are intentionally unexposed and fail-closed -pending real Cursor plus Bubblewrap host acceptance. Local IDs, state, -credentials, permissions, worktrees, and receipts never share the Cursor -Cloud ledger. The local wire identity is versioned independently at 0.1.0 -inside Cursor package 0.3.0. - -The deferred adapter contract requires an explicit absolute workspace in an -administrator-owned allowlist, an owner-only CLI home and permission -configuration, and a dedicated Cursor executable path. It specifies Ask mode -for read-only work and an isolated worktree for implementation in a future -accepted foundation, but neither provider run mode is operational in this -release. Generic -`agent` aliases, Cloud IDs, arbitrary shell commands, login/update commands, -and arbitrary MCP configuration are rejected. Status may report a pinned, -provider-free native sandbox preflight, but a digest or preflight alone is not -an execution attestation; direct foundation calls return -`foundation_not_exposed` in this release. +retains three contracts (`status`, `run`, and `runs`). The default wire catalog +exposes only `status` (with local/auth/permissions actions); an administrator +must set `CURSOR_LOCAL_CLI_ENABLE_HOST_TRUSTED_RUNS=1` before `run` and `runs` +appear. Local IDs, state, credentials, permissions, worktrees, and receipts +never share the Cursor Cloud ledger. The local wire identity is 0.2.0 inside +Cursor package 0.4.0; the cloud and local identities remain separate even +though they ship in one package. + +The host-trusted adapter requires an explicit absolute workspace in an +administrator-owned allowlist and a dedicated Cursor executable path. Every +run requires `execution_profile: "host_trusted"`. Read-only work uses Cursor +Ask mode, never `--force`, and requires explicit `Write(**)`, `Shell(*)`, and +`Mcp(*:*)` deny rules; implementation uses explicit `implement` mode, +`--force`, and an isolated Cursor worktree. Both modes invoke the direct +`cursor-agent` binary with no Bubblewrap outer boundary and inherit the MCP +process user's host authority. Receipts identify that authority and leave +`workspaceChanged` unknown because the wrapper has no outer filesystem +observer. Generic `agent` aliases, Cloud IDs, arbitrary shell commands, +login/update commands, and arbitrary MCP configuration remain rejected. + +Status may report a pinned, provider-free native sandbox preflight, but the +host-trusted run path never invokes it; a digest or preflight alone is not an +execution attestation. Sources: diff --git a/docs/target-contract.md b/docs/target-contract.md index dfe6c51..d233126 100644 --- a/docs/target-contract.md +++ b/docs/target-contract.md @@ -12,9 +12,19 @@ administrator-allowlist violations before credentials, deduplication, or process startup. The target fingerprint is SHA-256 over canonical JSON containing the resolved -workspace, cwd, Git common directory, exact HEAD, and filesystem device/inode -identity. The caller computes or records this value independently and sends it -as `expected_target_fingerprint`. A mismatch is fatal. +workspace, cwd, Git common directory, exact HEAD, normalized `allowed_paths`, +the authoritative `role`, and filesystem device/inode identity. Normal callers +should set `target_binding: "control_plane"` and receive the exact binding from +the control plane. Advanced callers may compute or record this value +independently, omit `target_binding`, and send it as +`expected_target_fingerprint`. A mismatch is fatal. + +For a staged private GitHub source, Git credentials must already be available +noninteractively to the MCP server process through an owner-approved helper or +secret-manager/askpass integration. Staging sets `GIT_TERMINAL_PROMPT=0`, so +interactive credentials cannot be entered during clone or ref resolution. +Repository URLs remain credential-free; credentials never belong in the +target contract or tool arguments. Prompts are task content only. A prompt-level `cd`, path, or claimed HEAD never changes target authority. diff --git a/examples/preflight-result.json b/examples/preflight-result.json index a074454..c1c91a5 100644 --- a/examples/preflight-result.json +++ b/examples/preflight-result.json @@ -1,15 +1,18 @@ { - "status": "passed", - "target_fingerprint": "sha256:0000000000000000000000000000000000000000000000000000000000000000", - "resolved_workspace": "/absolute/path/to/dsh-runtime-workspace", + "ok": true, + "schema_version": "codex-co-engineer.config.v1", + "target_fingerprint": "0000000000000000000000000000000000000000000000000000000000000000", + "expected_target_fingerprint": "0000000000000000000000000000000000000000000000000000000000000000", + "target_binding": "control_plane", + "target_match": true, + "resolved_workspace": "/absolute/path/to/local/checkouts/example", "resolved_cwd": "/absolute/path/to/local/checkouts/example", - "configuration_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "configuration_digest": "0000000000000000000000000000000000000000000000000000000000000000", "transport": "stdio", "protocol_version": "2025-11-25", "server_identity": { "name": "plumbob-harness-control", - "display_name": "Codex-Co-Engineer", - "version": "1.0.0" + "version": "2.2.0" }, - "available_tools": ["status", "runtime", "run", "jobs", "cancel"] + "available_tools": ["preflight", "status", "capacity", "runtime", "run", "jobs", "cancel"] } diff --git a/examples/target-context.json b/examples/target-context.json index ccfdfaf..84755a5 100644 --- a/examples/target-context.json +++ b/examples/target-context.json @@ -1,5 +1,6 @@ { "schema_version": "codex-co-engineer.target.v1", + "mode": "explicit", "working_directory": "/absolute/path/to/local/checkouts/example", "expected_git_root": "/absolute/path/to/local/checkouts/example", "expected_head": "0123456789abcdef0123456789abcdef01234567", @@ -7,6 +8,5 @@ "src", "tests" ], - "role": "review", - "expected_fingerprint": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + "role": "review" } diff --git a/plugins/cursor-cloud-control/.codex-plugin/plugin.json b/plugins/cursor-cloud-control/.codex-plugin/plugin.json index ccdc2b3..f085d78 100644 --- a/plugins/cursor-cloud-control/.codex-plugin/plugin.json +++ b/plugins/cursor-cloud-control/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cursor-cloud-control", - "version": "0.3.0", + "version": "0.4.0", "description": "Typed, safety-first Cursor Cloud Agents and local Cursor CLI control planes with separate state and receipts.", "author": { "name": "Plumbob" @@ -18,7 +18,7 @@ "interface": { "displayName": "Cursor Cloud Control", "shortDescription": "Safely operate Cursor Cloud Agents.", - "longDescription": "Use separate typed MCP surfaces for Cursor Cloud Agents API v1 and the local Cursor CLI. Cloud discovery and bounded agent lifecycle are operational; the local surface provides compact binary/auth/permission/sandbox status while its versioned run foundation remains intentionally unexposed pending real host acceptance. Local and cloud state, credentials, and receipts never mix.", + "longDescription": "Use separate typed MCP surfaces for Cursor Cloud Agents API v1 and the local Cursor CLI. Cloud discovery and bounded agent lifecycle are operational; the local surface defaults to compact status/auth/permission diagnostics and exposes an explicitly activated host-trusted direct-CLI run profile with separate local state, credentials, and receipts. Local and cloud state never mix.", "developerName": "Plumbob", "category": "Developer Tools", "capabilities": [ diff --git a/plugins/cursor-cloud-control/.mcp.json b/plugins/cursor-cloud-control/.mcp.json index d1f93ef..0a0a4c1 100644 --- a/plugins/cursor-cloud-control/.mcp.json +++ b/plugins/cursor-cloud-control/.mcp.json @@ -41,6 +41,7 @@ "CURSOR_LOCAL_CLI_HOME", "CURSOR_LOCAL_CLI_CONFIG_DIR", "CURSOR_LOCAL_CLI_WORKSPACE_ROOTS", + "CURSOR_LOCAL_CLI_ENABLE_HOST_TRUSTED_RUNS", "CURSOR_LOCAL_CONTROL_STATE_DIR", "XDG_STATE_HOME", "HOME", diff --git a/plugins/cursor-cloud-control/LICENSE b/plugins/cursor-cloud-control/LICENSE new file mode 100644 index 0000000..161b80b --- /dev/null +++ b/plugins/cursor-cloud-control/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Plumbob + +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/cursor-cloud-control/README.md b/plugins/cursor-cloud-control/README.md index 6c4108b..877947b 100644 --- a/plugins/cursor-cloud-control/README.md +++ b/plugins/cursor-cloud-control/README.md @@ -7,17 +7,18 @@ create durable agents; submit follow-up runs; observe bounded polling or SSE; cancel runs; read usage; handle artifacts; and archive, unarchive, or permanently delete agents. +This README documents Cursor Cloud Control `0.4.0`. Its separately exposed +`cursor-local-control` MCP server uses wire identity `0.2.0`. + The same package contains a separate `cursor-local-control` MCP server for the locally installed Cursor Agent CLI. Its foundation has exactly three -typed contracts—`status`, `run`, and `runs`—but the process-facing catalog -currently exposes only read-only `status` (with `local`, `auth`, and -`permissions` actions) pending host acceptance. It is deliberately not a -wrapper for the Cloud API. Local credentials, permission configuration, process lifecycle, +typed contracts—`status`, `run`, and `runs`—and the public/default catalog +exposes only read-only `status` (with `local`, `auth`, and `permissions` +actions). An administrator may explicitly opt into a clearly labeled +host-trusted direct-CLI profile. It is deliberately not a wrapper for the +Cloud API. Local credentials, permission configuration, process lifecycle, worktrees, logs, IDs, and owner-only receipts are separate from the Cloud -surface and its `submissions.json` ledger. Local status/auth/permissions -diagnostics are available in this release; provider execution remains -deliberately fail-closed and unwired pending real Cursor plus Bubblewrap host -acceptance inside a native boundary. +surface and its `submissions.json` ledger. The implementation is intentionally a control plane, not a generic HTTP proxy. Every operation is mapped to a documented v1 endpoint and every tool @@ -78,8 +79,9 @@ through the v1 run routes, so the plugin does not invent a second stream ID. ## Setup and key handling -Register this directory as a local Codex plugin and configure the MCP process -with one of these administrator-controlled credential sources: +Register the repository root as a Codex marketplace (see the root +[README](../../README.md)) and install `cursor-cloud-control`. Then configure +the MCP process with one of these administrator-controlled credential sources: ```text CURSOR_API_KEY= @@ -173,10 +175,10 @@ so the state problem can be diagnosed without submitting work. ### Cursor Local Control The second MCP server, `cursor-local-control`, is a separate local process -adapter. Its foundation has exactly three typed contracts, while the shipped -MCP catalog exposes only `status` until host acceptance proves the complete -boundary. It never imports the Cloud API client, reads `submissions.json`, -accepts Cloud IDs, or writes Cloud receipts. +adapter. It never imports the Cloud API client, reads `submissions.json`, +accepts Cloud IDs, or writes Cloud receipts. The default catalog is +status-only; run/lifecycle tools appear only when the administrator enables +host-trusted execution. Provision these administrator-only environment values before using local status: @@ -185,50 +187,73 @@ status: `cursor-local-agent`. The generic `agent` alias is rejected so an existing Grok alias cannot be shadowed. - `CURSOR_LOCAL_CLI_SHA256`: administrator-pinned SHA-256 digest for the - executable. Status reports digest drift; an unpinned or changed binary is - never eligible for execution. + executable. Status reports digest drift. Host-trusted execution remains + administrator-authorized even when no digest pin is configured; a configured + pin that drifts still fails closed. - `CURSOR_LOCAL_CLI_SANDBOX_BIN`: absolute path to the administrator-selected native `bwrap` binary. Only `bwrap` is accepted. - `CURSOR_LOCAL_CLI_SANDBOX_SHA256`: administrator-pinned SHA-256 digest for that native sandbox. Status runs a harmless read-only-root preflight and - reports `sandbox.ready`; this preflight alone does not enable provider runs. + reports `sandbox.ready`; host-trusted execution does not invoke this + foundation and the preflight is not an execution attestation. - `CURSOR_LOCAL_CLI_API_KEY`: optional local-only API key environment value. The adapter maps it to the child process's `CURSOR_API_KEY`; it never takes the Cloud key file implicitly and never accepts a key as a tool argument. -- `CURSOR_LOCAL_CLI_HOME`: owner-only (`0700`) directory reserved to isolate - CLI authentication and Cursor worktrees in a future accepted run surface. -- `CURSOR_LOCAL_CLI_CONFIG_DIR`: required owner-only (`0700`) directory whose - `cli-config.json` is administrator-managed. The config must be schema v1, - non-unrestricted, and deny `Mcp(*:*)`; read-only runs additionally require - `Write(**)` and `Shell(*)` in `permissions.deny` for a future read-only - execution profile. +- `CURSOR_LOCAL_CLI_HOME`: optional absolute directory for Cursor + authentication and worktrees. When absent, host-trusted execution uses the + MCP process `HOME`, matching the locally authenticated CLI. +- `CURSOR_LOCAL_CLI_CONFIG_DIR`: optional absolute Cursor config directory. + The host-trusted profile inherits Cursor's normal CLI approval configuration; + the wrapper does not claim that it is a sandbox or silently widen it. - `CURSOR_LOCAL_CLI_WORKSPACE_ROOTS`: absolute, colon-separated workspace allowlist. Tool callers cannot broaden it. +- `CURSOR_LOCAL_CLI_ENABLE_HOST_TRUSTED_RUNS`: set exactly to `1` by the + administrator to expose local `run` and `runs`. It is unset by default and + is never accepted as a tool argument. - `CURSOR_LOCAL_CONTROL_STATE_DIR`: owner-only (`0700`) local ledger root; absent this, the adapter uses an absolute `XDG_STATE_HOME` or `HOME` local - state path ending in `cursor-local-control`. - -The local `status` tool is available for binary, compact auth, permission, and -sandbox inspection. The `run` and `runs` foundation schemas are retained for -review and versioning, but this release intentionally advertises only -`status` and keeps provider execution disabled. Direct calls to `run` or -`runs` fail closed with `foundation_not_exposed`, and therefore never spawn or -adopt Cursor. The shipped MCP manifest does not include an activation switch. - -The deferred invocation contract retains explicit `read_only` and -`implement` modes for compatibility testing. A future host-acceptance release -must prove the real Cursor binary, worktree creation, resource limits, -permission enforcement, cancellation, and receipt behavior inside the native -boundary before enabling either mode. Until then, do not describe local runs -as operational or use this surface for provider execution. - -Host-acceptance blockers are explicit: the current Bubblewrap code is only a -provider-free status preflight (its prototype root read-only bind is not a -complete host confidentiality or network boundary); a real Cursor process -must be exercised with resource limits and an audited filesystem/network -policy. The future lifecycle test must also prove graceful termination, -forced escalation, process-group ownership, and receipt recovery across MCP -restarts. Digest pins alone are not an execution attestation. + state path ending in `cursor-local-control`. The ledger retains every + requestId/request digest reservation (including terminal tombstones) up to + 10,000 records and the 8 MiB file bound; it fails closed before spawning a + new process when either capacity is reached, rather than evicting a tombstone. + +The local `status` tool is always available for binary, compact auth, +permission, and sandbox diagnostics. To expose execution, set +`CURSOR_LOCAL_CLI_ENABLE_HOST_TRUSTED_RUNS=1`. Every `run` request must include +`execution_profile: "host_trusted"`, an explicit `mode` (`read_only` or +`implement`), and an absolute allowlisted workspace. + +Host-trusted read-only execution invokes `cursor-agent --print --mode ask`; +it never passes `--force` and requires explicit `Write(**)`, `Shell(*)`, and +`Mcp(*:*)` deny rules in the administrator Cursor config. Explicit implement +execution invokes Cursor with `--force` and an isolated Cursor worktree. Both +use the direct selected binary, +disable Cursor's provider sandbox, and do not invoke Bubblewrap. Receipts +identify the boundary as `host_trusted`, the authority as +`mcp_process_user`, the outer sandbox as `none`, and `workspaceChanged` as +`null` because a direct host process has no outer filesystem observer. + +This is a normal local coding-agent authority surface, not a confidentiality, +network, or filesystem sandbox. The process can use any authority available to +the MCP OS user; the workspace allowlist and bounded timeout/event/log fields +are control-plane limits only. Timeout and cancellation signal the owned +process group with TERM and escalate to KILL after the grace interval. + +Host-trusted pathname, home, and Cursor-config authority is the same-user +authority of the MCP process: owner-only checks and descriptor identity +attestations reduce accidental swaps, but there is no separate filesystem or +credential boundary. On restart, a child is signalled only when its durable +PID start token freshly matches immediately before each TERM/KILL. If the +leader has exited or the token cannot be matched, post-leader descendants are +left untouched and the run is reported as `transport_lost` rather than risking +a signal to a reused PID or unrelated process group. + +The retained Bubblewrap code remains a separately packaged foundation and is +not the host-trusted boundary. A real host acceptance check is still required +for each local installation, including Cursor project-state/trust setup and +process cleanup. The adapter still does not expose `login`, `logout`, +`update`, ACP, workers, arbitrary shell commands, or arbitrary MCP +configuration. The CLI invocation is based on Cursor's documented [headless](https://cursor.com/docs/cli/headless), [parameter](https://cursor.com/docs/cli/reference/parameters), @@ -255,6 +280,13 @@ Create defaults are deliberately conservative: network or timeout leaves acceptance uncertain, the ledger marks the submission `uncertain` and the same request ID cannot silently create a duplicate. +- cancellation and agent lifecycle mutations also receive a durable request + receipt. If a request ID is omitted for those target-scoped operations, the + plugin derives a stable target/action key; callers should provide an explicit + request ID when they need an independently addressable receipt. +- Cursor HTTP 409 conflicts and HTTP 429 rate limits are recorded as definitive + failed submissions (with a safe provider error code when available), not as + transport-uncertain mutations. - create receipts separate caller-requested configuration from provider verification. Repository starting refs, the effective model, and the remote workspace head/branch remain explicitly unverified unless Cursor returns a @@ -290,7 +322,7 @@ potentially tens of seconds long. The plugin therefore makes one bounded be used directly. Repository-backed creation receives the same longer one-attempt transport bound. -`agents` supports `list`, `get`, and `create`. A create call supplies a +`agents` supports `list`, `get`, `create`, and explicit `reconcile`. A create call supplies a prompt and may select a model, environment, repositories, prompt images, session environment variables, inline MCP servers (including remote `authEnv` and `headerEnv` references), custom subagents, and `agent`/`plan` mode. The @@ -302,7 +334,31 @@ does not attest them. For 0.2.x compatibility, `effectiveConfiguration` is still present with `provenance: "caller-derived"` and `deprecated: true`; it is only a legacy alias for the requested configuration. -`runs` supports `list`, `get`, `followup`, `wait`, `stream`, and `cancel`. +When `agentId` is omitted, the plugin does not send its local reservation ID +to Cursor; Cursor may mint the provider ID. A transport-uncertain create in +that mode may inspect a bounded provider listing using a hash-only fingerprint, +but even a unique exact match is not reservation-time evidence: an identical +agent may predate the request. The reservation therefore remains uncertain; +the plugin never guesses an ID, finalizes a listing match, or resubmits. Use +`{"action":"reconcile","requestId":"..."}` for the bounded diagnostic, or +use the explicit typed release confirmation `release:` only after +accepting that provider state could not be proven. For a caller-supplied provider ID, +reconciliation performs bounded, repeated `agents.get` and `runs.list` checks +and releases the retryable local reservation only after both provider paths +consistently return HTTP 404. + +`runs` supports `list`, `get`, `followup`, `wait`, `stream`, `cancel`, and +`reconcile`. Follow-up and cancellation mutations return durable receipts; +an otherwise successful follow-up response without an opaque provider run ID, +or a cancellation response whose run is not terminal `CANCELLED`/`CANCELED`, +remains uncertain. A caller-supplied create ID must also match the provider ID +returned by Cursor; mismatches remain uncertain. `runs.wait` converts a +provider `request_timeout` into a bounded `timedOut: true` receipt while +retaining the latest confirmed run when available. Uncertain reservations can +be reconciled with the exact provider run ID when one is known, or explicitly +released with `release:`. A provider run HTTP 404 is only treated as +absence after bounded repeated exact 404 observations; one miss remains +uncertain. No reconciliation path resubmits the mutation. `stream` parses fragmented SSE chunks, multiline data, comments, heartbeats, event IDs, unknown future event types, and the documented `status`/`assistant`/`thinking`/`tool_call`/`interaction_update`/`result`/ @@ -317,9 +373,14 @@ overwrite unless `overwrite=true` is explicit, write atomically with mode `0600`, and are never executed or rendered automatically. `usage` reads the documented per-agent token totals and per-run breakdown. -`lifecycle` archives or unarchives an exact agent. Permanent deletion is +`lifecycle` archives or unarchives an exact agent and supports +`reconcile` for uncertain lifecycle receipts. Permanent deletion is irreversible and requires `confirmation` exactly equal to -`delete:`; archive is the reversible alternative. +`delete:`; archive is the reversible alternative. Every lifecycle +mutation is durably keyed and never blindly retried. If provider state cannot +be proven, use the typed lifecycle reconciliation or the explicit +`release:` confirmation; release records a terminal local receipt +and does not claim that the provider mutation failed. ## Monitoring and cancellation diff --git a/plugins/cursor-cloud-control/mcp/client.mjs b/plugins/cursor-cloud-control/mcp/client.mjs index 814a4c4..ed287cc 100644 --- a/plugins/cursor-cloud-control/mcp/client.mjs +++ b/plugins/cursor-cloud-control/mcp/client.mjs @@ -1,4 +1,5 @@ -import { lstat, readFile } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import { lstat, open } from 'node:fs/promises'; import { isIP } from 'node:net'; import path from 'node:path'; import { @@ -27,6 +28,69 @@ export function defaultApiKeyFile(env = process.env) { return path.resolve(path.join(env.XDG_CONFIG_HOME ?? path.join(env.HOME ?? '.', '.config'), 'cursor-cloud-control', 'api-key')); } +function credentialFileIdentity(metadata) { + return { dev: metadata.dev, ino: metadata.ino }; +} + +function credentialFileError(code, message) { + return new CursorApiError(code, message); +} + +/** + * Read a credential through one O_NOFOLLOW file descriptor. The old + * lstat(path) -> readFile(path) sequence allowed a pathname replacement to + * redirect the second operation. Descriptor-bound reads plus an inode check + * before/after the read keep both the symlink and replacement cases fail + * closed. + */ +export async function readOwnerOnlyFile(fileName, { + emptyIsMissing = false, + permissionCode = 'credential_file_permissions', + errorCode = 'credential_file_error', +} = {}) { + const noFollow = fsConstants.O_NOFOLLOW; + if (!Number.isInteger(noFollow) || noFollow === 0) { + throw credentialFileError(errorCode, 'Secure credential-file reads are unavailable on this host.'); + } + let handle; + try { + handle = await open(fileName, fsConstants.O_RDONLY | noFollow); + } catch (error) { + if (error?.code === 'ENOENT') return null; + if (error?.code === 'ELOOP') throw credentialFileError(permissionCode, 'Credential file must not be a symbolic link.'); + throw credentialFileError(errorCode, 'Unable to inspect credential file.'); + } + try { + const before = await handle.stat(); + if (!before.isFile() || (before.mode & 0o077) !== 0 || before.nlink !== 1) { + throw credentialFileError(permissionCode, 'Credential file must be a regular owner-only file with one hard link.'); + } + const identity = credentialFileIdentity(before); + const contents = await handle.readFile({ encoding: 'utf8' }); + const after = await handle.stat(); + if (!after.isFile() || (after.mode & 0o077) !== 0 || after.nlink !== 1 + || credentialFileIdentity(after).dev !== identity.dev + || credentialFileIdentity(after).ino !== identity.ino) { + throw credentialFileError(permissionCode, 'Credential file changed during the read.'); + } + let pathMetadata; + try { + pathMetadata = await lstat(fileName); + } catch { + throw credentialFileError(permissionCode, 'Credential file changed during the read.'); + } + if (pathMetadata.isSymbolicLink() + || credentialFileIdentity(pathMetadata).dev !== identity.dev + || credentialFileIdentity(pathMetadata).ino !== identity.ino) { + throw credentialFileError(permissionCode, 'Credential file changed during the read.'); + } + const value = contents.trim(); + return value || (emptyIsMissing ? null : ''); + } finally { + await handle.close().catch(() => {}); + } +} + const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); const responseDeadlines = new WeakMap(); const responseMetadata = new WeakMap(); @@ -106,18 +170,7 @@ export async function loadApiKey(env = process.env, { pluginRoot } = {}) { throw new CursorApiError('invalid_configuration', 'CURSOR_API_KEY_FILE must point outside the plugin directory.'); } } - let metadata; - try { metadata = await lstat(fileName); } catch (error) { - if (error?.code === 'ENOENT') return null; - throw new CursorApiError('credential_file_error', 'Unable to inspect CURSOR_API_KEY_FILE.'); - } - if (!metadata.isFile() || metadata.isSymbolicLink()) throw new CursorApiError('credential_file_error', 'CURSOR_API_KEY_FILE is not a regular file.'); - if ((metadata.mode & 0o077) !== 0) throw new CursorApiError('credential_file_permissions', 'CURSOR_API_KEY_FILE must be owner-only (mode 0600 or stricter).'); - let content; - try { content = (await readFile(fileName, 'utf8')).trim(); } catch { - throw new CursorApiError('credential_file_error', 'Unable to read CURSOR_API_KEY_FILE.'); - } - return content || null; + return readOwnerOnlyFile(fileName, { emptyIsMissing: true }); } export function authHeaderFromKey(apiKey, scheme = 'bearer') { @@ -515,8 +568,8 @@ export class CursorApiClient { retryRead: false, }); } - listAgents(query) { return this.json('/v1/agents', { query }); } - getAgent(agentId) { return this.json(`/v1/agents/${encodeURIComponent(agentId)}`); } + listAgents(query, options = {}) { return this.json('/v1/agents', { ...options, query }); } + getAgent(agentId, options = {}) { return this.json(`/v1/agents/${encodeURIComponent(agentId)}`, options); } createAgent(body) { const timeoutMs = Array.isArray(body?.repos) && body.repos.length > 0 ? this.repositoryTimeoutMs @@ -528,8 +581,8 @@ export class CursorApiClient { timeoutMs, }); } - listRuns(agentId, query) { return this.json(`/v1/agents/${encodeURIComponent(agentId)}/runs`, { query }); } - getRun(agentId, runId) { return this.json(`/v1/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}`); } + listRuns(agentId, query, options = {}) { return this.json(`/v1/agents/${encodeURIComponent(agentId)}/runs`, { ...options, query }); } + getRun(agentId, runId, options = {}) { return this.json(`/v1/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}`, options); } createRun(agentId, body) { return this.json(`/v1/agents/${encodeURIComponent(agentId)}/runs`, { method: 'POST', body, retryRead: false }); } cancelRun(agentId, runId) { return this.json(`/v1/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}/cancel`, { method: 'POST', retryRead: false }); } streamRun(agentId, runId, options) { diff --git a/plugins/cursor-cloud-control/mcp/ledger.mjs b/plugins/cursor-cloud-control/mcp/ledger.mjs index 9e979cb..6b60e86 100644 --- a/plugins/cursor-cloud-control/mcp/ledger.mjs +++ b/plugins/cursor-cloud-control/mcp/ledger.mjs @@ -402,6 +402,12 @@ function validateRecord(record) { && /^[0-9a-f]{64}$/i.test(record.digest) && ['pending', 'completed', 'failed', 'uncertain'].includes(record.status) && (record.agentId === null || typeof record.agentId === 'string') + && (record.providerAgentId === undefined || record.providerAgentId === null + || typeof record.providerAgentId === 'string' && record.providerAgentId.length > 0 && record.providerAgentId.length <= 256) + && (record.providerNotFoundConfirmations === undefined + || Number.isInteger(record.providerNotFoundConfirmations) + && record.providerNotFoundConfirmations >= 0 + && record.providerNotFoundConfirmations <= 2) && typeof record.createdAt === 'string' && typeof record.updatedAt === 'string' && (record.owner === undefined || (record.owner && typeof record.owner === 'object' @@ -420,6 +426,80 @@ function timestampMilliseconds(timestamp) { return Number.isFinite(milliseconds) ? milliseconds : null; } +function activeReservation(record) { + return record?.status === 'pending' || record?.status === 'uncertain'; +} + +const RECONCILIATION_FIELDS = Object.freeze([ + 'reconciliationReason', + 'reconciliationRequired', + 'reconciledAt', + 'releasedAt', + 'staleAt', + 'recoveryReason', + 'failureCode', + 'providerCode', + 'providerNotFoundConfirmations', +]); + +const ATTEMPT_FIELDS = Object.freeze(['runId', 'providerRunId']); + +function clearAttemptMetadata(record, { clearAttempt = false } = {}) { + const output = { ...record }; + for (const field of RECONCILIATION_FIELDS) delete output[field]; + if (clearAttempt) for (const field of ATTEMPT_FIELDS) delete output[field]; + return output; +} + +function capRecords(records) { + const terminal = records.filter((record) => !activeReservation(record)); + const keptTerminal = terminal.slice(-MAX_RECORDS); + const terminalSet = new Set(keptTerminal); + // Preserve the original order so restart/replay semantics remain stable, + // while guaranteeing that no pending or uncertain reservation is evicted by + // terminal history growth. + return records.filter((record) => activeReservation(record) || terminalSet.has(record)); +} + +function recordProviderAgentId(record) { + // Records written before the providerAgentId field was introduced used the + // local agentId as the provider target. Preserve that legacy reconciliation + // behavior while making new generated IDs explicitly non-provider IDs. + // Lifecycle/cancellation reservations historically stored an explicit null + // providerAgentId even though their exact agent target lived in agentId. + // A non-null providerAgentId always wins; otherwise the exact stored agent + // target is the safe fallback. Provider-assigned creates have both fields + // null and therefore still cannot be guessed during reconciliation. + return record?.providerAgentId ?? record?.agentId ?? null; +} + +function findProviderReservation(records, requestId, providerId) { + if (providerId === null) return null; + return [...records.values()].find((record) => ( + record.requestId !== requestId + && activeReservation(record) + // New lifecycle/cancellation records intentionally carry an explicit + // null providerAgentId: their target is exact for reconciliation, but + // they are not create/follow-up provider-ID reservations that should + // block an unrelated operation on the same agent. Legacy records without + // the field retain their historical agentId reservation behavior. + && (Object.hasOwn(record, 'providerAgentId') ? record.providerAgentId : record.agentId ?? null) !== null + && (Object.hasOwn(record, 'providerAgentId') ? record.providerAgentId : record.agentId ?? null) === providerId + )) ?? null; +} + +function throwProviderReservationConflict(record) { + if (!record) return; + if (record.status === 'uncertain') { + throw new CursorApiError( + 'uncertain_submission', + 'A prior submission for this provider agent ID has an uncertain transport outcome; reconcile it before retrying.', + { ambiguous: true }, + ); + } + throw new CursorApiError('submission_in_progress', 'A submission for this provider agent ID is already in progress.'); +} + async function probeWritable(directory, snapshot) { const temporary = path.join(directory, `.submissions-probe-${process.pid}-${randomUUID()}.tmp`); let safeToCleanup = true; @@ -458,27 +538,140 @@ function processIsAlive(pid) { } } -async function readLockOwner(lockPath) { +function sameFileIdentity(left, right) { + return Boolean(left && right && left.dev === right.dev && left.ino === right.ino); +} + +async function readLockOwnerSnapshot(lockPath) { const ownerPath = path.join(lockPath, 'owner.json'); let metadata; try { metadata = await lstat(ownerPath); } catch (error) { - if (error?.code === 'ENOENT') return null; + if (error?.code === 'ENOENT') return { owner: null, present: false, identity: null, contents: null }; throw unavailable(error, ownerPath); } assertOwnerOnly(metadata, 'Submission ledger lock owner'); + const identity = fileIdentity(metadata); let owner; + let contents; + try { + contents = await readFile(ownerPath, 'utf8'); + } catch (error) { + if (error?.code === 'ENOENT') { + throw new CursorApiError('ledger_permissions', 'Submission ledger lock owner changed during stale-lock inspection.'); + } + throw unavailable(error, ownerPath); + } + let confirmed; try { - owner = JSON.parse(await readFile(ownerPath, 'utf8')); + confirmed = await lstat(ownerPath); + } catch (error) { + if (error?.code === 'ENOENT') { + throw new CursorApiError('ledger_permissions', 'Submission ledger lock owner changed during stale-lock inspection.'); + } + throw unavailable(error, ownerPath); + } + assertOwnerOnly(confirmed, 'Submission ledger lock owner'); + assertFileIdentity(confirmed, identity, 'Submission ledger lock owner'); + try { + owner = JSON.parse(contents); + } catch { + owner = null; + } + return { owner: validLockOwner(owner) ? owner : null, present: true, identity, contents }; +} + +async function readLockOwner(lockPath) { + return (await readLockOwnerSnapshot(lockPath)).owner; +} + +function lockIsStale(metadata, staleMs, clock) { + const observedAt = clock(); + return Number.isFinite(observedAt) + && Number.isFinite(metadata.mtimeMs) + && observedAt >= metadata.mtimeMs + && observedAt - metadata.mtimeMs >= staleMs; +} + +async function confirmStaleLockIdentity(lockPath, initial, ownerSnapshot, staleMs, clock) { + let confirmed; + try { + confirmed = await lstat(lockPath); } catch (error) { if (error?.code === 'ENOENT') return null; - return null; + throw unavailable(error, lockPath); + } + assertOwnerOnly(confirmed, 'Submission ledger lock', { directory: true }); + // The lock must remain the same old directory from the first observation + // through the owner read. A replacement lock (or a fresh mtime) is left for + // its owner; never remove a path merely because it has the same name. + if (!sameFileIdentity(initial, confirmed) + || confirmed.mtimeMs !== initial.mtimeMs + || !lockIsStale(confirmed, staleMs, clock)) return null; + + const ownerPath = path.join(lockPath, 'owner.json'); + if (ownerSnapshot.present) { + let currentOwner; + try { + currentOwner = await lstat(ownerPath); + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw unavailable(error, ownerPath); + } + assertOwnerOnly(currentOwner, 'Submission ledger lock owner'); + if (!sameFileIdentity(ownerSnapshot.identity, currentOwner)) return null; + // A stable inode is not enough if a writer replaced the contents in place; + // compare the bounded owner marker before deciding to unlink it. + let contents; + try { + contents = await readFile(ownerPath, 'utf8'); + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw unavailable(error, ownerPath); + } + if (contents !== ownerSnapshot.contents) return null; + } else { + try { + await lstat(ownerPath); + return null; + } catch (error) { + if (error?.code !== 'ENOENT') throw unavailable(error, ownerPath); + } + } + return confirmed; +} + +async function claimStaleLock(lockPath) { + const claimPath = path.join(lockPath, '.reclaiming'); + const claim = { pid: process.pid, token: randomUUID(), claimedAt: Date.now() }; + try { + await writeFile(claimPath, JSON.stringify(claim), { flag: 'wx', mode: 0o600 }); + const metadata = await lstat(claimPath); + assertOwnerOnly(metadata, 'Submission ledger stale-lock claim'); + return { path: claimPath, identity: fileIdentity(metadata), contents: JSON.stringify(claim) }; + } catch (error) { + if (error?.code === 'EEXIST') return null; + throw unavailable(error, claimPath); + } +} + +async function releaseStaleLockClaim(claim) { + if (!claim) return; + try { + const metadata = await lstat(claim.path); + assertOwnerOnly(metadata, 'Submission ledger stale-lock claim'); + if (!sameFileIdentity(metadata, claim.identity)) return; + await unlink(claim.path); + } catch (error) { + if (error?.code !== 'ENOENT') { + // Cleanup is best effort. The caller's stale-lock decision remains + // fail-closed if another process replaced the claim marker. + } } - return validLockOwner(owner) ? owner : null; } -async function removeStaleLock(lockPath, staleMs) { +async function removeStaleLock(lockPath, staleMs, clock = Date.now) { let metadata; try { metadata = await lstat(lockPath); @@ -487,23 +680,55 @@ async function removeStaleLock(lockPath, staleMs) { throw unavailable(error, lockPath); } assertOwnerOnly(metadata, 'Submission ledger lock', { directory: true }); - if (Date.now() - metadata.mtimeMs < staleMs) return false; - - const owner = await readLockOwner(lockPath); - // An unknown owner is deliberately never removed. A process can crash between - // mkdir(lock) and writing owner.json, so a malformed or absent marker must - // eventually time out rather than being guessed to be stale. - if (!owner || processIsAlive(owner.pid)) return false; - + if (!lockIsStale(metadata, staleMs, clock)) return false; + + const ownerSnapshot = await readLockOwnerSnapshot(lockPath); + const owner = ownerSnapshot.owner; + // A valid live owner always wins. An absent or malformed marker is + // reclaimable only after the age and identity checks below prove this is the + // same old lock directory and marker we inspected. + if (owner && processIsAlive(owner.pid)) return false; + const confirmed = await confirmStaleLockIdentity(lockPath, metadata, ownerSnapshot, staleMs, clock); + if (!confirmed) return false; + + // Claim the old directory before touching owner.json. A contender cannot + // create a replacement lock while this directory still exists, and a + // second reclaimer cannot race us through the fixed claim marker. The + // claim is removed only immediately before rmdir(lockPath); if a new owner + // wins that mkdir race, rmdir returns ENOTEMPTY and its marker is untouched. + const claim = await claimStaleLock(lockPath); + if (!claim) return false; + let removedOwner = false; const ownerPath = path.join(lockPath, 'owner.json'); try { - await unlink(ownerPath); + const claimedLock = await lstat(lockPath); + assertOwnerOnly(claimedLock, 'Submission ledger lock', { directory: true }); + if (!sameFileIdentity(metadata, claimedLock)) return false; + if (ownerSnapshot.present) { + const currentOwner = await lstat(ownerPath); + assertOwnerOnly(currentOwner, 'Submission ledger lock owner'); + if (!sameFileIdentity(ownerSnapshot.identity, currentOwner)) return false; + const currentContents = await readFile(ownerPath, 'utf8'); + if (currentContents !== ownerSnapshot.contents) return false; + await unlink(ownerPath); + removedOwner = true; + } + await releaseStaleLockClaim(claim); + const beforeRemove = await lstat(lockPath); + assertOwnerOnly(beforeRemove, 'Submission ledger lock', { directory: true }); + if (!sameFileIdentity(metadata, beforeRemove)) return false; await rmdir(lockPath); return true; } catch (error) { - if (error?.code === 'ENOENT') return true; + if (error?.code === 'ENOENT') return removedOwner; if (error?.code === 'ENOTEMPTY') return false; throw unavailable(error, lockPath); + } finally { + // If the lock was not removed, retain neither our claim nor a partially + // removed marker. Both cleanup operations are identity checked. + if (!removedOwner || await lstat(lockPath).then(() => true).catch(() => false)) { + await releaseStaleLockClaim(claim); + } } } @@ -511,6 +736,7 @@ async function acquireFileLock(directory, snapshot, { timeoutMs = DEFAULT_LOCK_TIMEOUT_MS, retryMs = DEFAULT_LOCK_RETRY_MS, staleMs = DEFAULT_LOCK_STALE_MS, + clock = Date.now, } = {}) { const lockPath = path.join(directory, 'submissions.lock'); const ownerPath = path.join(lockPath, 'owner.json'); @@ -557,7 +783,7 @@ async function acquireFileLock(directory, snapshot, { await rmdir(lockPath).catch(() => {}); } if (error?.code !== 'EEXIST') throw unavailable(error, lockPath); - await removeStaleLock(lockPath, staleMs); + await removeStaleLock(lockPath, staleMs, clock); } const remaining = deadline - Date.now(); @@ -634,7 +860,7 @@ export class SubmissionLedger { if (record.status !== 'pending' || this.ownerFor(record.requestId) || !this.isPendingStale(record)) return record; const recoveredAt = new Date(this.clock()).toISOString(); return { - ...record, + ...clearAttemptMetadata(record, { clearAttempt: true }), status: 'uncertain', updatedAt: recoveredAt, staleAt: recoveredAt, @@ -659,7 +885,12 @@ export class SubmissionLedger { if (parsed?.version !== LEDGER_VERSION || !Array.isArray(parsed.records) || parsed.records.some((record) => !validateRecord(record))) { throw new CursorApiError('ledger_corrupt', 'Submission ledger has an unsupported or invalid record format.'); } - for (const record of parsed.records.slice(-MAX_RECORDS)) { + const cappedRecords = capRecords(parsed.records); + // Persist terminal-history trimming during restart as well as on the + // next mutation. Active pending/uncertain reservations are retained by + // capRecords, so this write can only remove old terminal history. + if (cappedRecords.length !== parsed.records.length) recovered = true; + for (const record of cappedRecords) { const normalized = this.recoverPending(record); if (normalized !== record) recovered = true; records.set(record.requestId, normalized); @@ -683,7 +914,7 @@ export class SubmissionLedger { async persistUnlocked() { const snapshot = await inspectOrCreateDirectory(this.stateDir); - const records = [...this.records.values()].slice(-MAX_RECORDS); + const records = capRecords([...this.records.values()]); await writeLedgerFile(this.file, JSON.stringify({ version: LEDGER_VERSION, records }), snapshot); } @@ -700,6 +931,7 @@ export class SubmissionLedger { timeoutMs: this.lockTimeoutMs, retryMs: this.lockRetryMs, staleMs: this.lockStaleMs, + clock: this.clock, }); try { // A ledger instance may have read an older snapshot while another MCP @@ -738,7 +970,16 @@ export class SubmissionLedger { return this.withFileLock(async () => this.records.get(requestId) ?? null); } - async begin({ requestId, kind, digest, agentId = null }) { + async begin({ + requestId, + kind, + digest, + agentId = null, + providerAgentId = agentId, + runId = undefined, + reconciliationFingerprint = null, + reconciliationHints = null, + }) { return this.withMutation(async () => { const existing = this.records.get(requestId); if (existing) { @@ -758,12 +999,17 @@ export class SubmissionLedger { throw new CursorApiError('submission_in_progress', 'A submission with this request ID is already in progress.'); } if (existing.status === 'failed') { + throwProviderReservationConflict(findProviderReservation(this.records, requestId, providerAgentId)); const timestamp = new Date(this.clock()).toISOString(); const owner = { pid: process.pid, token: randomUUID(), startedAt: timestamp }; const record = { - ...existing, + ...clearAttemptMetadata(existing, { clearAttempt: true }), status: 'pending', agentId: agentId ?? existing.agentId ?? null, + providerAgentId: providerAgentId ?? existing.providerAgentId ?? null, + ...(runId !== undefined ? { runId: runId ?? null } : {}), + ...(reconciliationFingerprint ? { reconciliationFingerprint } : {}), + ...(reconciliationHints ? { reconciliationHints } : {}), owner, updatedAt: timestamp, }; @@ -774,9 +1020,30 @@ export class SubmissionLedger { } return { duplicate: true, record: existing }; } + + // An explicit provider ID is also an idempotency boundary. If a prior + // request with that ID has an uncertain transport outcome, accepting a + // different request ID could create a duplicate agent after the first + // request eventually becomes visible at Cursor. Keep the reservation + // live until the caller explicitly reconciles it. + throwProviderReservationConflict(findProviderReservation(this.records, requestId, providerAgentId)); + const timestamp = new Date(this.clock()).toISOString(); const owner = { pid: process.pid, token: randomUUID(), startedAt: timestamp }; - const record = { requestId, kind, digest, status: 'pending', agentId, owner, createdAt: timestamp, updatedAt: timestamp }; + const record = { + requestId, + kind, + digest, + status: 'pending', + agentId, + providerAgentId: providerAgentId ?? null, + ...(runId !== undefined ? { runId: runId ?? null } : {}), + ...(reconciliationFingerprint ? { reconciliationFingerprint } : {}), + ...(reconciliationHints ? { reconciliationHints } : {}), + owner, + createdAt: timestamp, + updatedAt: timestamp, + }; this.records.set(requestId, record); this.setOwner(requestId, owner); await this.persistUnlocked(); @@ -791,7 +1058,7 @@ export class SubmissionLedger { throw new CursorApiError('ledger_record_missing', 'The durable submission record disappeared before completion could be recorded.'); } const record = { - ...current, + ...clearAttemptMetadata(current), ...fields, status: 'completed', updatedAt: new Date(this.clock()).toISOString(), @@ -807,6 +1074,7 @@ export class SubmissionLedger { // age into uncertain and be reconciled after a restart. this.clearOwner(requestId, current.owner); } + return { duplicate: false, record }; }); } @@ -816,7 +1084,13 @@ export class SubmissionLedger { if (!current) { throw new CursorApiError('ledger_record_missing', 'The durable submission record disappeared before failure could be recorded.'); } - const record = { ...current, ...fields, status: 'failed', updatedAt: new Date(this.clock()).toISOString() }; + const record = { + ...clearAttemptMetadata(current), + ...fields, + status: 'failed', + reconciliationRequired: false, + updatedAt: new Date(this.clock()).toISOString(), + }; if (record.agentId === undefined) record.agentId = null; this.records.set(requestId, record); try { @@ -841,8 +1115,15 @@ export class SubmissionLedger { digest: fields.digest, status: 'uncertain', agentId: fields.agentId ?? null, + ...(fields.providerAgentId !== undefined ? { providerAgentId: fields.providerAgentId } : {}), ...(fields.runId ? { runId: fields.runId } : {}), + ...(Number.isInteger(fields.providerNotFoundConfirmations) + ? { providerNotFoundConfirmations: fields.providerNotFoundConfirmations } + : {}), + ...(fields.reconciliationFingerprint ? { reconciliationFingerprint: fields.reconciliationFingerprint } : {}), + ...(fields.reconciliationHints ? { reconciliationHints: fields.reconciliationHints } : {}), recoveryReason: 'missing_final_record', + reconciliationRequired: true, createdAt: timestamp, updatedAt: timestamp, }; @@ -853,7 +1134,13 @@ export class SubmissionLedger { if ((fields.kind && fields.kind !== current.kind) || (fields.digest && fields.digest !== current.digest)) { throw new CursorApiError('request_id_conflict', 'The request ID was already used for a different operation.'); } - const record = { ...current, ...fields, status: 'uncertain', updatedAt: new Date(this.clock()).toISOString() }; + const record = { + ...clearAttemptMetadata(current), + ...fields, + status: 'uncertain', + reconciliationRequired: true, + updatedAt: new Date(this.clock()).toISOString(), + }; if (record.agentId === undefined) record.agentId = null; this.records.set(requestId, record); try { @@ -863,4 +1150,96 @@ export class SubmissionLedger { } }); } + + /** + * Finalize an uncertain reservation after the provider has been checked + * through an explicit, bounded reconciliation path. This is deliberately + * narrower than fail(): callers cannot attach arbitrary fields or release a + * live reservation by accident. The resulting failed record is retryable, + * while its reconciliation metadata prevents a second reconciliation from + * being mistaken for a fresh provider observation. + */ + async reconcile(requestId, { agentId } = {}) { + return this.withMutation(async () => { + const current = this.records.get(requestId); + if (!current) { + throw new CursorApiError('ledger_record_missing', 'The durable submission record disappeared before reconciliation could be recorded.'); + } + if (current.status === 'failed' && current.reconciliationReason === 'provider_not_found') { + return { duplicate: true, record: current }; + } + if (current.status === 'completed') return { duplicate: true, record: current }; + if (current.status !== 'uncertain') { + if (current.status === 'pending') { + throw new CursorApiError('submission_in_progress', 'The submission is still in progress; reconcile it only after transport uncertainty is recorded.'); + } + throw new CursorApiError('reconciliation_not_required', 'The submission does not require provider-absence reconciliation.'); + } + const currentProviderAgentId = recordProviderAgentId(current); + if (currentProviderAgentId === null) { + throw new CursorApiError('reconciliation_target_missing', 'The uncertain reservation has no stored provider agent ID to reconcile.'); + } + if (agentId !== undefined && currentProviderAgentId !== null && currentProviderAgentId !== agentId) { + throw new CursorApiError('reconciliation_target_mismatch', 'The provider agent ID does not match the uncertain reservation.'); + } + + const timestamp = new Date(this.clock()).toISOString(); + const record = { + ...current, + agentId: agentId ?? current.agentId ?? null, + providerAgentId: agentId ?? currentProviderAgentId, + status: 'failed', + failureCode: 'provider_not_found', + reconciliationReason: 'provider_not_found', + reconciliationRequired: false, + reconciledAt: timestamp, + updatedAt: timestamp, + }; + this.records.set(requestId, record); + try { + await this.persistUnlocked(); + } finally { + this.clearOwner(requestId, current.owner); + } + return { duplicate: false, record }; + }); + } + + /** + * Explicitly release an uncertain reservation after the caller has accepted + * that provider state could not be proven. This never contacts Cursor and + * never resubmits the original mutation; the durable receipt remains in the + * terminal failed history with an explicit operator-release reason. + */ + async release(requestId, { reason = 'operator_release' } = {}) { + return this.withMutation(async () => { + const current = this.records.get(requestId); + if (!current) { + throw new CursorApiError('ledger_record_missing', 'The durable submission record disappeared before release could be recorded.'); + } + if (current.status === 'failed' && current.reconciliationReason === reason) return { duplicate: true, record: current }; + if (current.status === 'completed') return { duplicate: true, record: current }; + if (current.status !== 'uncertain') { + if (current.status === 'pending') throw new CursorApiError('submission_in_progress', 'The submission is still in progress; release it only after uncertainty is recorded.'); + throw new CursorApiError('reconciliation_not_required', 'The submission does not require uncertainty release.'); + } + const timestamp = new Date(this.clock()).toISOString(); + const record = { + ...clearAttemptMetadata(current), + status: 'failed', + failureCode: 'uncertain_released', + reconciliationReason: reason, + reconciliationRequired: false, + releasedAt: timestamp, + updatedAt: timestamp, + }; + this.records.set(requestId, record); + try { + await this.persistUnlocked(); + } finally { + this.clearOwner(requestId, current.owner); + } + return { duplicate: false, record }; + }); + } } diff --git a/plugins/cursor-cloud-control/mcp/local.mjs b/plugins/cursor-cloud-control/mcp/local.mjs index 6d638bd..691e2a8 100644 --- a/plugins/cursor-cloud-control/mcp/local.mjs +++ b/plugins/cursor-cloud-control/mcp/local.mjs @@ -10,15 +10,16 @@ import { createHash, randomUUID } from 'node:crypto'; import { execFile as nodeExecFile, spawn as nodeSpawn } from 'node:child_process'; -import { lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises'; -import { createReadStream } from 'node:fs'; +import { lstat, mkdir, open, readFile, realpath, rename, rm, unlink, writeFile } from 'node:fs/promises'; +import { constants, readFileSync } from 'node:fs'; import path from 'node:path'; import readline from 'node:readline'; +import { TextDecoder } from 'node:util'; import { redactError, redactText } from './redaction.mjs'; export const MCP_PROTOCOL_VERSION = '2025-11-25'; export const SUPPORTED_MCP_PROTOCOL_VERSIONS = Object.freeze(['2025-11-25', '2024-11-05']); -export const SERVER_IDENTITY = Object.freeze({ name: 'cursor-local-control', version: '0.1.0' }); +export const SERVER_IDENTITY = Object.freeze({ name: 'cursor-local-control', version: '0.2.0' }); export const DEFAULT_TIMEOUT_MS = 120_000; export const MAX_TIMEOUT_MS = 600_000; @@ -31,13 +32,26 @@ export const MAX_BYTES = 5_000_000; export const MAX_PROMPT_CHARS = 40_000; export const MAX_MODEL_CHARS = 200; export const MAX_WORKSPACE_CHARS = 4_096; +export const HOST_TRUSTED_RUNS_ENV = 'CURSOR_LOCAL_CLI_ENABLE_HOST_TRUSTED_RUNS'; export const LOCAL_RUN_ID_PATTERN = /^lrun-[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/; export const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/; const SAFE_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+:/ -]{0,255}$/; const SAFE_EVENT_TYPE_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/; const MAX_EVENT_LINE_BYTES = 256 * 1024; const MAX_BINARY_BYTES = 512 * 1024 * 1024; -const MAX_LEDGER_RECORDS = 200; +// Request IDs/digests are durable tombstones. Never evict the oldest 200 (or +// any other terminal subset): once this bounded reservation ledger or its +// byte budget is full, a new request fails closed before spawn. The limit is +// deliberately high enough for normal local use while remaining finite. +export const MAX_LOCAL_LEDGER_RECORDS = 10_000; +const MAX_LEDGER_RECORDS = MAX_LOCAL_LEDGER_RECORDS; +const DEFAULT_LEDGER_LOCK_TIMEOUT_MS = 10_000; +const DEFAULT_LEDGER_LOCK_STALE_MS = 30_000; +const DEFAULT_LEDGER_LOCK_POLL_MS = 25; +const MAX_LEDGER_LOCK_POLL_MS = 1_000; +const MAX_LEDGER_FILE_BYTES = 8 * 1024 * 1024; +const NOFOLLOW = constants.O_NOFOLLOW; +const DIRECTORY = constants.O_DIRECTORY; const SAFE_CHILD_PATH = '/usr/local/bin:/usr/bin:/bin'; const SANDBOX_DIGEST_PATTERN = /^[a-f0-9]{64}$/; @@ -79,6 +93,7 @@ function string(value, label, { min = 0, max = 1000, pattern, optional = false } if (typeof value !== 'string' || value.length < min || value.length > max) { fail(`${label} must be a string of ${min}-${max} characters.`); } + if (value.includes('\u0000')) fail(`${label} must not contain NUL bytes.`); if (pattern && !pattern.test(value)) fail(`${label} has an invalid format.`); return value; } @@ -118,6 +133,7 @@ export const TOOL_SCHEMAS = Object.freeze({ workspace: { type: 'string', minLength: 1, maxLength: MAX_WORKSPACE_CHARS, pattern: '^/' }, prompt: { type: 'string', minLength: 1, maxLength: MAX_PROMPT_CHARS }, mode: { type: 'string', enum: ['read_only', 'implement'] }, + execution_profile: { type: 'string', enum: ['host_trusted'] }, model: { type: 'string', minLength: 1, maxLength: MAX_MODEL_CHARS }, timeoutMs: { type: 'integer', minimum: 1_000, maximum: MAX_TIMEOUT_MS, default: DEFAULT_TIMEOUT_MS }, waitMs: { type: 'integer', minimum: 0, maximum: MAX_WAIT_MS, default: DEFAULT_WAIT_MS }, @@ -125,10 +141,11 @@ export const TOOL_SCHEMAS = Object.freeze({ maxBytes: { type: 'integer', minimum: 1_024, maximum: MAX_BYTES, default: DEFAULT_MAX_BYTES }, requestId: { type: 'string', minLength: 8, maxLength: 128, pattern: REQUEST_ID_PATTERN.source }, }, - required: ['workspace', 'prompt', 'mode'], + required: ['workspace', 'prompt', 'mode', 'execution_profile', 'requestId'], additionalProperties: false, }, runs: { + type: 'object', oneOf: [ { type: 'object', @@ -175,12 +192,19 @@ export const FOUNDATION_TOOLS = Object.freeze([ }, ]); -// The process-facing catalog is intentionally status-only until a real host -// acceptance run proves the native boundary around a Cursor process. The -// typed run/runs foundation remains packaged for review and future versioning, -// but it is not reachable through this release's MCP surface. +// The public/default process-facing catalog is status-only. An administrator +// may explicitly opt into the host-trusted direct-CLI profile; that profile +// never invokes the retained Bubblewrap foundation or claims an outer sandbox. export const TOOLS = Object.freeze([FOUNDATION_TOOLS[0]]); +export function hostTrustedRunsEnabled(env = process.env) { + return env[HOST_TRUSTED_RUNS_ENV] === '1'; +} + +export function toolsForEnvironment(env = process.env) { + return hostTrustedRunsEnabled(env) ? FOUNDATION_TOOLS : TOOLS; +} + export function validateToolInput(name, rawArguments = {}) { const value = object(rawArguments); if (!Object.hasOwn(TOOL_SCHEMAS, name)) throw new LocalInputError('unknown_tool', `Unknown local tool ${name}.`); @@ -191,15 +215,17 @@ export function validateToolInput(name, rawArguments = {}) { return { ...value, action: value.action ?? 'local' }; } if (name === 'run') { - unknown(value, ['workspace', 'prompt', 'mode', 'model', 'timeoutMs', 'waitMs', 'maxEvents', 'maxBytes', 'requestId']); + unknown(value, ['workspace', 'prompt', 'mode', 'execution_profile', 'model', 'timeoutMs', 'waitMs', 'maxEvents', 'maxBytes', 'requestId']); absolutePath(value.workspace, 'arguments.workspace'); string(value.prompt, 'arguments.prompt', { min: 1, max: MAX_PROMPT_CHARS }); if (!['read_only', 'implement'].includes(value.mode)) fail('arguments.mode must be read_only or implement.'); + if (value.execution_profile !== 'host_trusted') fail('arguments.execution_profile must be host_trusted.'); string(value.model, 'arguments.model', { min: 1, max: MAX_MODEL_CHARS, optional: true }); integer(value.timeoutMs, 'arguments.timeoutMs', { min: 1_000, max: MAX_TIMEOUT_MS, optional: true }); integer(value.waitMs, 'arguments.waitMs', { min: 0, max: MAX_WAIT_MS, optional: true }); integer(value.maxEvents, 'arguments.maxEvents', { min: 1, max: MAX_EVENTS, optional: true }); integer(value.maxBytes, 'arguments.maxBytes', { min: 1_024, max: MAX_BYTES, optional: true }); + if (value.requestId === undefined) fail('arguments.requestId is required for durable local runs.'); requestId(value.requestId); return value; } @@ -260,26 +286,81 @@ async function secureDirectory(directory, label = 'Local state directory') { } async function secureFile(file, label = 'Local ledger', { allowMissing = true } = {}) { - let metadata; - try { metadata = await lstat(file); } catch (error) { + if (!Number.isInteger(NOFOLLOW) || NOFOLLOW === 0) throw new LocalRuntimeError('state_unavailable', `${label} requires O_NOFOLLOW support.`); + let handle; + try { + handle = await open(file, constants.O_RDONLY | NOFOLLOW); + } catch (error) { if (allowMissing && error?.code === 'ENOENT') return false; throw new LocalRuntimeError(error?.code ?? 'state_unavailable', `Unable to inspect ${label}.`); } - assertOwnerOnly(metadata, label); - if (metadata.nlink !== 1 || (metadata.mode & 0o7777) !== 0o600) throw new LocalRuntimeError('state_permissions', `${label} must have mode 0600 and one hard link.`); - return true; + try { + const metadata = await handle.stat(); + assertOwnerOnly(metadata, label); + if (metadata.nlink !== 1 || (metadata.mode & 0o7777) !== 0o600) throw new LocalRuntimeError('state_permissions', `${label} must have mode 0600 and one hard link.`); + return true; + } finally { + await handle.close().catch(() => {}); + } +} + +async function readSecureFile(file, label, { allowMissing = false, maxBytes = MAX_LEDGER_FILE_BYTES } = {}) { + if (!Number.isInteger(NOFOLLOW) || NOFOLLOW === 0) throw new LocalRuntimeError('state_unavailable', `${label} requires O_NOFOLLOW support.`); + let handle; + try { + handle = await open(file, constants.O_RDONLY | NOFOLLOW); + } catch (error) { + if (allowMissing && error?.code === 'ENOENT') return null; + throw new LocalRuntimeError(error?.code ?? 'state_unavailable', `Unable to read ${label}.`); + } + try { + const metadata = await handle.stat(); + assertOwnerOnly(metadata, label); + if (metadata.nlink !== 1 || (metadata.mode & 0o7777) !== 0o600) throw new LocalRuntimeError('state_permissions', `${label} must have mode 0600 and one hard link.`); + if (metadata.size > maxBytes) throw new LocalRuntimeError('state_corrupt', `${label} exceeds its size bound.`); + return await handle.readFile({ encoding: 'utf8' }); + } catch (error) { + if (error instanceof LocalRuntimeError) throw error; + throw new LocalRuntimeError('state_unavailable', `Unable to read ${label}.`); + } finally { + await handle.close().catch(() => {}); + } } function digest(value) { return createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex'); } +function capLedgerRuns(runs, maxRecords = MAX_LEDGER_RECORDS) { + if (runs.length > maxRecords) { + throw new LocalRuntimeError('state_limit', `The local request ledger is at capacity (${maxRecords} durable reservations).`); + } + return runs; +} + export class LocalRunLedger { - constructor({ stateDir, source = 'environment', reason = null } = {}) { + constructor({ + stateDir, + source = 'environment', + reason = null, + lockTimeoutMs = DEFAULT_LEDGER_LOCK_TIMEOUT_MS, + staleLockMs = DEFAULT_LEDGER_LOCK_STALE_MS, + lockPollMs = DEFAULT_LEDGER_LOCK_POLL_MS, + maxRecords = MAX_LEDGER_RECORDS, + } = {}) { this.stateDir = stateDir ?? null; this.source = source; this.reason = reason; this.file = this.stateDir ? path.join(this.stateDir, 'runs.json') : null; + this.lockFile = this.stateDir ? path.join(this.stateDir, 'runs.lock') : null; + this.lockTimeoutMs = Number.isInteger(lockTimeoutMs) && lockTimeoutMs >= 0 ? lockTimeoutMs : DEFAULT_LEDGER_LOCK_TIMEOUT_MS; + this.staleLockMs = Number.isInteger(staleLockMs) && staleLockMs >= 0 ? staleLockMs : DEFAULT_LEDGER_LOCK_STALE_MS; + this.lockPollMs = Number.isInteger(lockPollMs) && lockPollMs > 0 + ? Math.min(lockPollMs, MAX_LEDGER_LOCK_POLL_MS) + : DEFAULT_LEDGER_LOCK_POLL_MS; + this.maxRecords = Number.isSafeInteger(maxRecords) && maxRecords > 0 && maxRecords <= MAX_LEDGER_RECORDS + ? maxRecords + : MAX_LEDGER_RECORDS; this.queue = Promise.resolve(); } @@ -300,25 +381,25 @@ export class LocalRunLedger { return readiness; } - async read() { - await this.ensure(); - let content; - try { content = await readFile(this.file, 'utf8'); } catch (error) { - if (error?.code === 'ENOENT') return { version: 1, runs: [] }; - throw new LocalRuntimeError('state_unavailable', 'Unable to read the local run ledger.'); - } + async _readUnlocked() { + const content = await readSecureFile(this.file, 'Local ledger', { allowMissing: true }); + if (content === null) return { version: 1, runs: [] }; try { const parsed = JSON.parse(content); - if (parsed?.version !== 1 || !Array.isArray(parsed.runs) || parsed.runs.length > MAX_LEDGER_RECORDS) throw new Error('invalid shape'); + if (parsed?.version !== 1 || !Array.isArray(parsed.runs)) throw new Error('invalid shape'); + parsed.runs = capLedgerRuns(parsed.runs, this.maxRecords); return parsed; - } catch { + } catch (error) { + if (error instanceof LocalRuntimeError) throw error; throw new LocalRuntimeError('state_corrupt', 'The local run ledger is corrupt.'); } } - async write(value) { - await this.ensure(); - const payload = JSON.stringify({ version: 1, runs: value.runs.slice(-MAX_LEDGER_RECORDS) }, null, 2); + async _writeUnlocked(value) { + const payload = JSON.stringify({ version: 1, runs: capLedgerRuns(value.runs, this.maxRecords) }, null, 2); + if (Buffer.byteLength(payload, 'utf8') > MAX_LEDGER_FILE_BYTES) { + throw new LocalRuntimeError('state_limit', 'The local run ledger exceeds its size bound.'); + } const temporary = `${this.file}.tmp-${process.pid}-${randomUUID()}`; await writeFile(temporary, payload, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); try { @@ -331,36 +412,215 @@ export class LocalRunLedger { } } + async _lockOwner(lockDirectory) { + const ownerFile = path.join(lockDirectory, 'owner.json'); + let metadata; + try { + metadata = await lstat(lockDirectory); + } catch (error) { + if (error?.code === 'ENOENT') return { present: false, owner: null, metadata: null }; + throw new LocalRuntimeError('state_lock_unavailable', 'Unable to inspect the local ledger lock.'); + } + assertOwnerOnly(metadata, 'Local ledger lock', { directory: true }); + if ((metadata.mode & 0o7777) !== 0o700) throw new LocalRuntimeError('state_lock_permissions', 'Local ledger lock must have mode 0700.'); + let ownerMetadata; + try { + const ownerStat = await lstat(ownerFile); + await secureFile(ownerFile, 'Local ledger lock owner', { allowMissing: false }); + if (ownerStat.size > 4 * 1024) throw new LocalRuntimeError('state_lock_corrupt', 'Local ledger lock owner metadata is too large.'); + ownerMetadata = JSON.parse(await readSecureFile(ownerFile, 'Local ledger lock owner', { maxBytes: 4 * 1024 })); + } catch (error) { + if (error?.code === 'ENOENT') return { present: true, owner: null, metadata }; + if (error instanceof LocalRuntimeError && error.code !== 'state_lock_corrupt') throw error; + ownerMetadata = null; + } + if (ownerMetadata !== null && ( + !ownerMetadata || typeof ownerMetadata !== 'object' || Array.isArray(ownerMetadata) + || !Number.isSafeInteger(ownerMetadata.pid) || ownerMetadata.pid < 1 + || typeof ownerMetadata.start !== 'string' || ownerMetadata.start.length > 128 + || typeof ownerMetadata.token !== 'string' || !/^[a-f0-9-]{16,128}$/u.test(ownerMetadata.token) + || typeof ownerMetadata.acquiredAt !== 'string' + )) ownerMetadata = null; + return { present: true, owner: ownerMetadata, metadata }; + } + + async _processStart(pid) { + if (process.platform === 'win32') return null; + try { + const text = await readFile(`/proc/${pid}/stat`, 'utf8'); + const close = text.lastIndexOf(') '); + if (close < 0) return null; + const fields = text.slice(close + 2).trim().split(/\s+/u); + return /^\d+$/u.test(fields[19] ?? '') ? fields[19] : null; + } catch (error) { + if (error?.code === 'ENOENT') return null; + return null; + } + } + + async _ownerAlive(owner) { + if (!owner || !Number.isSafeInteger(owner.pid) || owner.pid < 1) return false; + if (owner.start && owner.start !== 'unknown') { + const currentStart = await this._processStart(owner.pid); + if (currentStart !== null) return currentStart === owner.start; + } + try { + process.kill(owner.pid, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } + } + + async _staleLock(lockInfo) { + if (!lockInfo.present) return false; + if (lockInfo.owner && await this._ownerAlive(lockInfo.owner)) return false; + const ageMs = Math.max(0, Date.now() - (lockInfo.metadata?.mtimeMs ?? Date.now())); + // A lock with a proven dead owner is reclaimable immediately. A lock + // whose owner record was never committed (for example, a crashed process + // between mkdir and writeFile) needs an age bound before reclamation. + return Boolean(lockInfo.owner) || ageMs >= this.staleLockMs; + } + + async _reclaimStaleLock() { + const quarantine = `${this.lockFile}.stale-${process.pid}-${randomUUID()}`; + try { + await rename(this.lockFile, quarantine); + } catch (error) { + if (error?.code === 'ENOENT') return true; + if (error?.code === 'EEXIST') return false; + throw new LocalRuntimeError('state_lock_unavailable', 'Unable to quarantine a stale local ledger lock.'); + } + try { + await rm(quarantine, { recursive: true, force: true }); + } catch { + // The lock has already been removed from the active path. Do not let a + // best-effort quarantine cleanup strand all future ledger operations. + } + return true; + } + + async _acquireLock() { + await this.ensure(); + const deadline = Date.now() + this.lockTimeoutMs; + const token = randomUUID(); + const owner = { + pid: process.pid, + start: await this._processStart(process.pid) ?? 'unknown', + token, + acquiredAt: new Date().toISOString(), + }; + let created = false; + while (true) { + try { + await mkdir(this.lockFile, { mode: 0o700 }); + created = true; + await secureDirectory(this.lockFile, 'Local ledger lock'); + const ownerFile = path.join(this.lockFile, 'owner.json'); + await writeFile(ownerFile, `${JSON.stringify(owner)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + await secureFile(ownerFile, 'Local ledger lock owner', { allowMissing: false }); + let released = false; + return async () => { + if (released) return; + released = true; + const lockInfo = await this._lockOwner(this.lockFile); + if (!lockInfo.present) return; + if (!lockInfo.owner || lockInfo.owner.token !== token) { + throw new LocalRuntimeError('state_lock_lost', 'The local ledger lock owner changed before release.'); + } + const quarantine = `${this.lockFile}.release-${process.pid}-${token}`; + try { + await rename(this.lockFile, quarantine); + } catch (error) { + if (error?.code === 'ENOENT') return; + throw new LocalRuntimeError('state_lock_lost', 'Unable to release the local ledger lock.'); + } + await rm(quarantine, { recursive: true, force: true }); + }; + } catch (error) { + if (created) { + created = false; + await rm(this.lockFile, { recursive: true, force: true }).catch(() => {}); + } + if (error?.code !== 'EEXIST') { + if (error instanceof LocalRuntimeError) throw error; + throw new LocalRuntimeError('state_lock_unavailable', 'Unable to acquire the local ledger lock.'); + } + const lockInfo = await this._lockOwner(this.lockFile); + if (!lockInfo.present) continue; + if (await this._staleLock(lockInfo)) { + await this._reclaimStaleLock(); + continue; + } + if (Date.now() >= deadline) throw new LocalRuntimeError('state_lock_timeout', 'The local run ledger is busy in another MCP process.'); + await sleep(Math.min(this.lockPollMs, Math.max(1, deadline - Date.now()))); + } + } + } + + async withLock(operation) { + if (typeof operation !== 'function') throw new LocalRuntimeError('invalid_input', 'Ledger lock operation must be a function.'); + const release = await this._acquireLock(); + try { return await operation(); } finally { await release(); } + } + + async read() { + await this.flush(); + return this.withLock(() => this._readUnlocked()); + } + + async write(value) { + return this.withLock(() => this._writeUnlocked(value)); + } + + _enqueue(operation) { + const pending = this.queue.catch(() => {}).then(operation); + this.queue = pending.catch(() => {}); + return pending; + } + + async flush() { + await this.queue; + } + async update(localRunId, updater) { - this.queue = this.queue.then(async () => { - const current = await this.read(); + return this._enqueue(async () => this.withLock(async () => { + const current = await this._readUnlocked(); const index = current.runs.findIndex((entry) => entry.localRunId === localRunId); if (index < 0) return null; current.runs[index] = updater(structuredClone(current.runs[index])); - await this.write(current); + await this._writeUnlocked(current); return current.runs[index]; - }); - return this.queue; + })); } async add(record) { - this.queue = this.queue.then(async () => { - const current = await this.read(); + return this._enqueue(async () => this.withLock(async () => { + const current = await this._readUnlocked(); + const existing = current.runs.find((entry) => entry.localRunId === record.localRunId + || (record.requestId && entry.requestId === record.requestId)); + if (existing) { + if (existing.requestDigest !== record.requestDigest) { + throw new LocalRuntimeError('request_conflict', 'The local requestId was already used for a different request.'); + } + return existing; + } current.runs = current.runs.filter((entry) => entry.localRunId !== record.localRunId && !(record.requestId && entry.requestId === record.requestId)); current.runs.push(record); - await this.write(current); + await this._writeUnlocked(current); return record; - }); - return this.queue; + })); } async find(localRunId) { + await this.flush(); const current = await this.read(); return current.runs.find((entry) => entry.localRunId === localRunId) ?? null; } async findRequest(requestId) { if (!requestId) return null; + await this.flush(); const current = await this.read(); return current.runs.find((entry) => entry.requestId === requestId) ?? null; } @@ -407,18 +667,55 @@ async function allowedWorkspace(workspace, env) { return resolved; } +async function attestDirectory(value, label, { ownerOnly = false } = {}) { + if (!value || !path.isAbsolute(value)) throw new LocalRuntimeError('invalid_configuration', `${label} must be an absolute directory.`); + if (!Number.isInteger(NOFOLLOW) || NOFOLLOW === 0 || !Number.isInteger(DIRECTORY) || DIRECTORY === 0) { + throw new LocalRuntimeError('state_unavailable', `${label} requires secure directory descriptor support.`); + } + let requested; + try { requested = await lstat(value); } catch (error) { + throw new LocalRuntimeError('configuration_unavailable', `${label} is unavailable.`, { cause: error?.code }); + } + if (requested.isSymbolicLink()) throw new LocalRuntimeError('state_permissions', `${label} must not be a symbolic link.`); + let resolved; + try { resolved = await realpath(value); } catch { throw new LocalRuntimeError('configuration_unavailable', `${label} is unavailable.`); } + let handle; + try { + handle = await open(resolved, constants.O_RDONLY | DIRECTORY | NOFOLLOW); + const metadata = await handle.stat(); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new LocalRuntimeError('state_permissions', `${label} must be a real directory.`); + if (ownerOnly) { + assertOwnerOnly(metadata, label, { directory: true }); + if ((metadata.mode & 0o7777) !== 0o700) throw new LocalRuntimeError('state_permissions', `${label} must have mode 0700.`); + } + return { path: resolved, identity: fileIdentity(metadata) }; + } catch (error) { + if (error instanceof LocalRuntimeError) throw error; + throw new LocalRuntimeError('configuration_unavailable', `${label} is unavailable.`); + } finally { + await handle?.close().catch(() => {}); + } +} + async function ownerOnlyPath(value, label) { - if (!value || !path.isAbsolute(value)) throw new LocalRuntimeError('invalid_configuration', `${label} must be an absolute path.`); + return (await attestDirectory(value, label, { ownerOnly: true })).path; +} + +async function existingDirectory(value, label) { + if (!value || !path.isAbsolute(value)) throw new LocalRuntimeError('invalid_configuration', `${label} must be an absolute directory.`); let metadata; - try { metadata = await lstat(value); } catch { throw new LocalRuntimeError('configuration_unavailable', `${label} is unavailable.`); } - assertOwnerOnly(metadata, label, { directory: true }); - if ((metadata.mode & 0o7777) !== 0o700) throw new LocalRuntimeError('state_permissions', `${label} must have mode 0700.`); + try { metadata = await lstat(value); } catch (error) { + throw new LocalRuntimeError('configuration_unavailable', `${label} is unavailable.`, { cause: error?.code }); + } + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new LocalRuntimeError('invalid_configuration', `${label} must be a real directory.`); + } return value; } function configDirectory(env) { const configured = nonEmpty(env.CURSOR_LOCAL_CLI_CONFIG_DIR); - if (configured && path.isAbsolute(configured)) return path.resolve(configured); + if (configured) return path.isAbsolute(configured) ? path.resolve(configured) : null; const home = nonEmpty(env.HOME); if (home && path.isAbsolute(home)) return path.join(home, '.cursor'); return null; @@ -429,38 +726,47 @@ async function inspectPermissionConfig(env, workspace) { if (!directory) return { configured: false, reason: 'CURSOR_LOCAL_CLI_CONFIG_DIR or absolute HOME is required.' }; let metadata; try { metadata = await lstat(directory); } catch { return { configured: false, path: path.join(directory, 'cli-config.json'), reason: 'config directory is unavailable' }; } - if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) { + try { + assertOwnerOnly(metadata, 'Cursor local CLI config directory', { directory: true }); + if ((metadata.mode & 0o7777) !== 0o700) throw new LocalRuntimeError('state_permissions', 'Cursor local CLI config directory must have mode 0700.'); + } catch { return { configured: false, path: path.join(directory, 'cli-config.json'), reason: 'config directory must be a real owner-only directory' }; } const globalPath = path.join(directory, 'cli-config.json'); let global = null; try { - await lstat(globalPath); - await secureFile(globalPath, 'Cursor local CLI config', { allowMissing: false }); - global = JSON.parse(await readFile(globalPath, 'utf8')); + global = JSON.parse(await readSecureFile(globalPath, 'Cursor local CLI config', { allowMissing: false, maxBytes: 256 * 1024 })); } catch (error) { return { configured: false, path: globalPath, reason: error.code === 'ENOENT' ? 'cli-config.json is absent' : 'cli-config.json is invalid or not owner-only' }; } if (global?.version !== 1 || !global.permissions || !Array.isArray(global.permissions.allow) || !Array.isArray(global.permissions.deny)) { return { configured: false, path: globalPath, reason: 'cli-config.json does not match schema version 1' }; } - const projectPath = workspace ? path.join(workspace, '.cursor', 'cli.json') : null; + const projectDirectory = workspace ? path.join(workspace, '.cursor') : null; + const projectPath = projectDirectory ? path.join(projectDirectory, 'cli.json') : null; let project = null; - if (projectPath) { + if (projectDirectory) { try { - await lstat(projectPath); - await secureFile(projectPath, 'Project Cursor local CLI permissions', { allowMissing: false }); - project = JSON.parse(await readFile(projectPath, 'utf8')); + const projectDirectoryMetadata = await lstat(projectDirectory); + assertOwnerOnly(projectDirectoryMetadata, 'Project Cursor local CLI config directory', { directory: true }); + if ((projectDirectoryMetadata.mode & 0o7777) !== 0o700) throw new LocalRuntimeError('state_permissions', 'Project Cursor local CLI config directory must have mode 0700.'); + project = JSON.parse(await readSecureFile(projectPath, 'Project Cursor local CLI permissions', { allowMissing: false, maxBytes: 256 * 1024 })); if (!project?.permissions || !Array.isArray(project.permissions.allow) || !Array.isArray(project.permissions.deny)) { return { configured: false, path: globalPath, projectPath, reason: 'project cli.json has an invalid permission shape' }; } + if (project.approvalMode !== undefined && typeof project.approvalMode !== 'string') { + return { configured: false, path: globalPath, projectPath, reason: 'project cli.json has an invalid approval mode' }; + } } catch (error) { if (error?.code !== 'ENOENT') return { configured: false, path: globalPath, projectPath, reason: 'project cli.json is invalid or not owner-only' }; } } const allow = [...global.permissions.allow, ...(project?.permissions?.allow ?? [])]; const deny = [...global.permissions.deny, ...(project?.permissions?.deny ?? [])]; - const approvalMode = global.approvalMode ?? 'default'; + const approvalModes = [global.approvalMode, project?.approvalMode].filter((value) => typeof value === 'string'); + const approvalMode = approvalModes.includes('unrestricted') + ? 'unrestricted' + : (project?.approvalMode ?? global.approvalMode ?? 'default'); return { configured: true, path: globalPath, @@ -485,37 +791,62 @@ function permissionReady(config, mode) { if (!config.denyMcpAll) throw new LocalRuntimeError('permission_config_unsafe', 'Local runs require an explicit Mcp(*:*) deny rule unless a future allowlist is implemented.'); } +function fileIdentity(metadata) { + return { dev: String(metadata.dev), ino: String(metadata.ino) }; +} + +function sameFileIdentity(left, right) { + return left?.dev === String(right?.dev) && left?.ino === String(right?.ino); +} + async function binaryMetadata(binaryPath, { expectedSha256 = null, label = 'binary' } = {}) { if (!binaryPath) return { available: false, path: null, reason: 'binary path is not configured' }; + if (!Number.isInteger(NOFOLLOW) || NOFOLLOW === 0) return { available: false, path: binaryPath, reason: `${label} requires O_NOFOLLOW support` }; let metadata; try { metadata = await lstat(binaryPath); } catch (error) { return { available: false, path: binaryPath, reason: error?.code === 'ENOENT' ? 'binary is absent' : 'binary is unavailable' }; } if (metadata.isSymbolicLink()) { - try { binaryPath = await realpath(binaryPath); metadata = await lstat(binaryPath); } catch { return { available: false, path: binaryPath, reason: 'binary symlink target is unavailable' }; } - } - if (!metadata.isFile() || (metadata.mode & 0o111) === 0) return { available: false, path: binaryPath, reason: `${label} must be an executable regular file` }; - if (metadata.nlink !== 1 || (metadata.mode & 0o022) !== 0) return { available: false, path: binaryPath, reason: `${label} must not be group/other-writable and must have one hard link` }; - if (metadata.size > MAX_BINARY_BYTES) return { available: false, path: binaryPath, reason: 'binary exceeds the configured size bound' }; - const hash = createHash('sha256'); - await new Promise((resolve, reject) => { - const stream = createReadStream(binaryPath); - stream.on('data', (chunk) => hash.update(chunk)); - stream.on('error', reject); - stream.on('end', resolve); - }); - const sha256 = hash.digest('hex'); - const digestConfigured = typeof expectedSha256 === 'string' && SANDBOX_DIGEST_PATTERN.test(expectedSha256); - return { - available: true, - path: binaryPath, - sha256, - expectedSha256: digestConfigured ? expectedSha256 : null, - digestConfigured, - drift: digestConfigured ? sha256 !== expectedSha256 : null, - sizeBytes: metadata.size, - mode: metadata.mode & 0o7777, - }; + try { binaryPath = await realpath(binaryPath); } catch { return { available: false, path: binaryPath, reason: 'binary symlink target is unavailable' }; } + } + let handle; + try { + handle = await open(binaryPath, constants.O_RDONLY | NOFOLLOW); + metadata = await handle.stat(); + if (!metadata.isFile() || (metadata.mode & 0o111) === 0) return { available: false, path: binaryPath, reason: `${label} must be an executable regular file` }; + if (metadata.nlink !== 1 || (metadata.mode & 0o022) !== 0) return { available: false, path: binaryPath, reason: `${label} must not be group/other-writable and must have one hard link` }; + if (metadata.size > MAX_BINARY_BYTES) return { available: false, path: binaryPath, reason: 'binary exceeds the configured size bound' }; + const identity = fileIdentity(metadata); + const hash = createHash('sha256'); + const buffer = Buffer.allocUnsafe(128 * 1024); + let offset = 0; + while (offset < metadata.size) { + const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, metadata.size - offset), offset); + if (bytesRead === 0) return { available: false, path: binaryPath, reason: `${label} changed while it was being read` }; + hash.update(buffer.subarray(0, bytesRead)); + offset += bytesRead; + } + const after = await handle.stat(); + if (!sameFileIdentity(identity, after) || after.size !== metadata.size) return { available: false, path: binaryPath, reason: `${label} changed while it was being read` }; + const sha256 = hash.digest('hex'); + const digestConfigured = typeof expectedSha256 === 'string' && SANDBOX_DIGEST_PATTERN.test(expectedSha256); + return { + available: true, + path: binaryPath, + identity, + sha256, + expectedSha256: digestConfigured ? expectedSha256 : null, + digestConfigured, + drift: digestConfigured ? sha256 !== expectedSha256 : null, + sizeBytes: metadata.size, + mode: metadata.mode & 0o7777, + }; + } catch (error) { + if (error instanceof LocalRuntimeError) throw error; + return { available: false, path: binaryPath, reason: error?.code === 'ELOOP' ? `${label} must not be a symbolic link` : `${label} is unavailable` }; + } finally { + await handle?.close().catch(() => {}); + } } export function resolveSandbox(env = process.env) { @@ -587,13 +918,18 @@ function childEnvironment(env, { home, configDir }) { return output; } -export function buildArguments({ workspace, prompt, mode, model, worktreeName }) { - const args = ['--print', '--output-format', 'stream-json', '--stream-partial-output', '--sandbox', 'enabled', '--trust', '--workspace', workspace]; +export function buildArguments({ workspace, prompt, mode, model, worktreeName, executionProfile = 'host_trusted' }) { + if (executionProfile !== 'host_trusted') throw new LocalInputError('invalid_input', 'Only the host_trusted execution profile is exposed.'); + const args = ['--print', '--output-format', 'stream-json', '--stream-partial-output', '--sandbox', 'disabled', '--trust', '--workspace', workspace]; if (mode === 'implement') args.push('--worktree', worktreeName); if (mode === 'read_only') args.push('--mode', 'ask'); else args.push('--force'); if (model !== undefined) args.push('--model', model); - args.push(prompt); + // Cursor's headless CLI accepts the prompt as a positional argument. Keep + // the conventional option terminator immediately before it so prompt text + // beginning with "--endpoint", "--plugin-dir", "--force", or any future + // option can never be reparsed as a CLI flag. + args.push('--', prompt); return args; } @@ -640,47 +976,162 @@ function normalizeEvent(value, secrets) { } export function createNdjsonCollector({ maxEvents = DEFAULT_MAX_EVENTS, maxBytes = DEFAULT_MAX_BYTES, secrets = [], onEvent = () => {} } = {}) { - let pending = ''; + let pending = Buffer.alloc(0); let bytes = 0; let truncated = false; + let limitReached = false; + let invalidUtf8 = false; + let finished = false; const events = []; - const parseLine = (line) => { - const lineBytes = Buffer.byteLength(line, 'utf8'); - if (lineBytes > MAX_EVENT_LINE_BYTES) { truncated = true; return; } - bytes += lineBytes; - if (bytes > maxBytes) { truncated = true; return; } + const strictDecoder = new TextDecoder('utf-8', { fatal: true }); + const stop = ({ invalid = false, dropped = false } = {}) => { + if (invalid) invalidUtf8 = true; + if (dropped) truncated = true; + limitReached = true; + pending = Buffer.alloc(0); + }; + const parseLine = (lineBytes) => { + let payload = lineBytes; + if (payload.at(-1) === 0x0d) payload = payload.subarray(0, payload.length - 1); + const lineBytesLength = payload.byteLength; + if (lineBytesLength > MAX_EVENT_LINE_BYTES) { stop({ dropped: true }); return; } + if (bytes + lineBytesLength > maxBytes) { stop({ dropped: true }); return; } + let line; + try { + // Decode complete byte-delimited lines strictly. Buffer.toString() + // would replace malformed bytes, and decoding each stream chunk would + // corrupt a multibyte character split across chunks. + line = strictDecoder.decode(payload); + } catch { + stop({ invalid: true, dropped: true }); + return; + } + bytes += lineBytesLength; + if (bytes > maxBytes) { truncated = true; limitReached = true; return; } let parsed; try { parsed = JSON.parse(line); } catch { parsed = { type: 'invalid', message: 'invalid JSON event' }; } const event = normalizeEvent(parsed, secrets); - if (events.length < maxEvents) events.push(event); - else truncated = true; + if (events.length >= maxEvents) { + truncated = true; + limitReached = true; + return; + } + events.push(event); onEvent(event); + if (events.length >= maxEvents || bytes >= maxBytes) limitReached = true; }; return { push(chunk) { - if (truncated && bytes >= maxBytes) return; - pending += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk); - if (Buffer.byteLength(pending, 'utf8') > MAX_EVENT_LINE_BYTES && !pending.includes('\n')) { truncated = true; return; } - let index; - while ((index = pending.indexOf('\n')) >= 0) { - const line = pending.slice(0, index).replace(/\r$/, ''); - pending = pending.slice(index + 1); + if (finished || limitReached) { + if (chunk && (Buffer.isBuffer(chunk) ? chunk.length : String(chunk).length > 0)) truncated = true; + return; + } + let value; + try { + value = Buffer.isBuffer(chunk) ? chunk : chunk instanceof Uint8Array ? Buffer.from(chunk) : Buffer.from(String(chunk), 'utf8'); + } catch { + stop({ invalid: true, dropped: true }); + return; + } + if (value.length === 0) return; + let offset = 0; + while (offset < value.length && !limitReached) { + const newline = value.indexOf(0x0a, offset); + const end = newline < 0 ? value.length : newline; + const segment = value.subarray(offset, end); + if (pending.length + segment.length > MAX_EVENT_LINE_BYTES) { + stop({ dropped: true }); + break; + } + if (segment.length > 0) pending = pending.length === 0 ? Buffer.from(segment) : Buffer.concat([pending, segment]); + if (newline < 0) break; + const line = pending; + pending = Buffer.alloc(0); parseLine(line); - if (bytes >= maxBytes || truncated && events.length >= maxEvents) break; + offset = newline + 1; + if (limitReached) { + if (offset < value.length) truncated = true; + break; + } + // Empty lines are valid JSON input boundaries but are represented as + // bounded invalid events, matching the previous collector contract. + } + }, + finish() { + if (!finished && pending.length > 0 && !limitReached) parseLine(pending); + finished = true; + pending = Buffer.alloc(0); + return { events, bytes: Math.min(bytes, maxBytes), truncated, invalidUtf8 }; + }, + }; +} + +// Capture arbitrary stderr bytes without decoding each stream chunk in +// isolation. TextDecoder's streaming mode carries incomplete UTF-8 sequences +// across pushes; fatal mode makes malformed input fail closed instead of +// inserting replacement characters that could alter/redact secrets +// unpredictably. Redaction is deliberately performed by the caller only +// after finish() has reassembled the complete bounded string. +function createStrictTextCollector({ maxBytes = DEFAULT_MAX_BYTES } = {}) { + let decoder = new TextDecoder('utf-8', { fatal: true }); + let text = ''; + let bytes = 0; + let truncated = false; + let invalidUtf8 = false; + let finished = false; + + const failClosed = () => { + invalidUtf8 = true; + decoder = null; + text = ''; + }; + + return { + push(chunk) { + if (finished || invalidUtf8) { + if (chunk && (Buffer.isBuffer(chunk) ? chunk.length : String(chunk).length > 0)) truncated = true; + return; + } + let value; + try { + value = Buffer.isBuffer(chunk) + ? chunk + : chunk instanceof Uint8Array + ? Buffer.from(chunk) + : Buffer.from(String(chunk), 'utf8'); + } catch { + failClosed(); + return; + } + if (value.length === 0) return; + const remaining = Math.max(0, maxBytes - bytes); + const accepted = value.subarray(0, remaining); + if (accepted.length < value.length) truncated = true; + if (accepted.length === 0) return; + bytes += accepted.length; + try { + text += decoder.decode(accepted, { stream: true }); + } catch { + failClosed(); } }, finish() { - if (pending && bytes < maxBytes && !truncated) parseLine(pending); - return { events, bytes: Math.min(bytes, maxBytes), truncated }; + if (!finished) { + finished = true; + if (decoder) { + try { text += decoder.decode(); } catch { failClosed(); } + } + } + return { text: invalidUtf8 ? '' : text, bytes, truncated, invalidUtf8 }; }, }; } -function processKill(child) { +function processKill(child, signal = 'SIGTERM') { if (!child?.pid) return false; try { - if (process.platform === 'win32') child.kill('SIGTERM'); - else process.kill(-child.pid, 'SIGTERM'); + if (process.platform === 'win32') child.kill(signal); + else process.kill(-child.pid, signal); return true; } catch (error) { if (error?.code === 'ESRCH') return false; @@ -688,6 +1139,87 @@ function processKill(child) { } } +function processGroupExists(pid) { + if (!pid || process.platform === 'win32') return false; + try { + process.kill(-pid, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } +} + +export async function terminateProcessGroup(child, { graceMs = 2_000, startToken = null } = {}) { + if (!child?.pid) return false; + // A PID is not an ownership proof. Active runs pass the exact start token + // captured immediately after spawn; recovery passes the durable token and + // requires a fresh procfs match. Unknown/unreadable identities are never + // signalled, even if a process group happens to exist at that number. + if (typeof startToken !== 'string' || startToken.length === 0 || startToken === 'unknown') return false; + if (!processGroupExists(child.pid)) return false; + // The token is re-read immediately before TERM even for the MCP process + // that originally spawned this child. Durable ownership is not a shortcut + // around PID-reuse protection. + if (!(await processMatches(child.pid, startToken))) { + throw new LocalRuntimeError('process_identity_changed', 'The owned process identity changed before termination.'); + } + const signaled = processKill(child, 'SIGTERM'); + if (!signaled) return false; + const deadline = Date.now() + graceMs; + while (processGroupExists(child.pid) && Date.now() < deadline) await sleep(25); + if (processGroupExists(child.pid)) { + // Re-check immediately before the escalation signal as well. If the + // leader exited, descendants are no longer safely attributable to this + // PID token; leave them alone and let reconciliation report transport_lost. + if (!(await processMatches(child.pid, startToken))) { + throw new LocalRuntimeError('process_identity_changed', 'The owned process identity changed before termination escalation.'); + } + try { processKill(child, 'SIGKILL'); } catch (error) { + if (error?.code !== 'ESRCH') throw error; + } + } + return true; +} + +async function processStartToken(pid) { + if (!Number.isSafeInteger(pid) || pid < 1 || process.platform === 'win32') return null; + try { + const text = await readFile(`/proc/${pid}/stat`, 'utf8'); + const close = text.lastIndexOf(') '); + if (close < 0) return null; + const fields = text.slice(close + 2).trim().split(/\s+/u); + return /^\d+$/u.test(fields[19] ?? '') ? fields[19] : null; + } catch { + return null; + } +} + +function processStartTokenSync(pid) { + if (!Number.isSafeInteger(pid) || pid < 1 || process.platform === 'win32') return null; + try { + const text = readFileSync(`/proc/${pid}/stat`, 'utf8'); + const close = text.lastIndexOf(') '); + if (close < 0) return null; + const fields = text.slice(close + 2).trim().split(/\s+/u); + return /^\d+$/u.test(fields[19] ?? '') ? fields[19] : null; + } catch { + return null; + } +} + +async function processMatches(pid, start) { + if (!Number.isSafeInteger(pid) || pid < 1) return false; + if (typeof start !== 'string' || start === 'unknown') return false; + const current = await processStartToken(pid); + return current !== null && current === start; +} + +async function terminateProcessGroupByPid(pid, startToken, { graceMs = 2_000 } = {}) { + if (!Number.isSafeInteger(pid) || pid < 1 || process.platform === 'win32') return false; + const fakeChild = { pid }; + return terminateProcessGroup(fakeChild, { graceMs, startToken }); +} + async function execText(execFileImpl, file, args, options = {}) { return new Promise((resolve, reject) => { execFileImpl(file, args, { ...options, encoding: 'utf8', maxBuffer: 256 * 1024 }, (error, stdout, stderr) => { @@ -745,17 +1277,82 @@ function scrub(value, secrets) { return value; } +function serializedBytes(value) { + return Buffer.byteLength(JSON.stringify(value), 'utf8'); +} + +function clipEventToBytes(event, maxBytes) { + if (maxBytes <= 0) return null; + if (serializedBytes(event) <= maxBytes) return event; + // Normalized provider events only carry untrusted text in these fields. + // Clip by Unicode code points (never split a UTF-16 surrogate) and retain + // the event discriminator/metadata so the receipt remains interpretable. + for (const key of ['text', 'result']) { + if (typeof event?.[key] !== 'string') continue; + const points = Array.from(event[key]); + let low = 0; + let high = points.length; + let best = null; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const candidate = { ...event, [key]: points.slice(0, middle).join('') }; + if (serializedBytes(candidate) <= maxBytes) { + best = candidate; + low = middle + 1; + } else high = middle - 1; + } + if (best) return best; + } + return null; +} + +function boundEvents(events, { maxEvents = DEFAULT_MAX_EVENTS, maxBytes = DEFAULT_MAX_BYTES, secrets = [] } = {}) { + const bounded = []; + let bytes = 0; + let truncated = false; + for (const source of events ?? []) { + if (bounded.length >= maxEvents) { + truncated = true; + break; + } + const safe = scrub(source, secrets); + const remaining = maxBytes - bytes; + const event = clipEventToBytes(safe, remaining); + if (!event) { + truncated = true; + break; + } + const eventBytes = serializedBytes(event); + bounded.push(event); + bytes += eventBytes; + if (eventBytes < serializedBytes(safe)) truncated = true; + } + if ((events?.length ?? 0) > bounded.length) truncated = true; + return { events: bounded, bytes, truncated }; +} + function publicRecord(record, { maxEvents = DEFAULT_MAX_EVENTS, maxBytes = DEFAULT_MAX_BYTES, secrets = [] } = {}) { const output = structuredClone(record); + const bounded = boundEvents(record.logs?.events ?? [], { maxEvents, maxBytes, secrets }); output.logs = { format: 'stream-json', - events: (record.logs?.events ?? []).slice(0, maxEvents), - bytes: Math.min(record.logs?.bytes ?? 0, maxBytes), - truncated: Boolean(record.logs?.truncated), + events: bounded.events, + // This is the actual UTF-8 size of the normalized events returned above, + // not the provider's unbounded/raw counter. + bytes: bounded.bytes, + truncated: Boolean(record.logs?.truncated) || bounded.truncated, + ...(record.logs?.invalidUtf8 ? { invalidUtf8: true } : {}), }; delete output.pid; delete output.argv; delete output.promptDigest; + if (output.execution && typeof output.execution === 'object') { + delete output.execution.ownerPid; + delete output.execution.ownerStart; + delete output.execution.childPid; + delete output.execution.childStart; + delete output.execution.processGroupId; + } return scrub(output, secrets); } @@ -767,10 +1364,44 @@ export class CursorLocalService { const state = resolveStateDirectory(env); this.ledger = ledger ?? new LocalRunLedger({ stateDir: state.directory, source: state.source, reason: state.reason }); this.active = new Map(); + this.ownerStartPromise = processStartToken(process.pid); } secrets() { return localSecrets(this.env); } + tools() { return toolsForEnvironment(this.env); } + + async reconcilePersistedRuns() { + const snapshot = await this.ledger.read(); + for (const persisted of snapshot.runs) { + if (!['accepted', 'started', 'working'].includes(persisted.lifecycle) || this.active.has(persisted.localRunId)) continue; + const execution = persisted.execution ?? {}; + const ownerPid = execution.ownerPid; + const ownerStart = execution.ownerStart; + // A live owner may be another MCP process that is still responsible for + // its child. Only a dead owner is recoverable by this process. + if (await processMatches(ownerPid, ownerStart)) continue; + const childPid = execution.childPid; + const childStart = execution.childStart; + const childCanBeIdentified = typeof childStart === 'string' && childStart !== 'unknown'; + const childAlive = childCanBeIdentified + ? await processMatches(childPid, childStart) + : false; + // Never turn an unverified PID into a signal target. An unreadable or + // missing durable start token is itself transport loss; the next owner + // records that fact but leaves unrelated processes untouched. + if (childAlive) await terminateProcessGroupByPid(childPid, childStart).catch(() => {}); + await this.ledger.update(persisted.localRunId, (entry) => ({ + ...entry, + lifecycle: 'terminal', + terminalState: 'transport_lost', + error: 'owner_process_lost', + finishedAt: entry.finishedAt ?? new Date().toISOString(), + durationMs: entry.durationMs ?? null, + })); + } + } + async binaryStatus() { const resolved = resolveBinary(this.env); const expectedSha256 = nonEmpty(this.env.CURSOR_LOCAL_CLI_SHA256); @@ -788,6 +1419,7 @@ export class CursorLocalService { const binary = await this.binaryStatus(); const config = workspace ? await inspectPermissionConfig(this.env, workspace).catch((error) => ({ configured: false, reason: error.message })) : await inspectPermissionConfig(this.env); const sandbox = await nativeSandboxStatus(this.env, this.execFileImpl); + const hostTrusted = hostTrustedRunsEnabled(this.env); const local = { surface: 'local-cli', contractVersion: 1, @@ -828,11 +1460,16 @@ export class CursorLocalService { ...(sandbox.reason ? { reason: sandbox.reason } : {}), }, safety: { - runEnabled: false, + runEnabled: hostTrusted, + executionProfile: hostTrusted ? 'host_trusted' : null, + boundary: hostTrusted ? 'host_trusted' : 'status_only', + authority: hostTrusted ? 'mcp_process_user' : null, + outerSandbox: 'none', + providerSandbox: hostTrusted ? 'disabled' : 'not_used', sandboxReady: sandbox.ready === true, - runUnavailableReason: 'Local provider execution is intentionally deferred pending real host acceptance of Cursor inside the native boundary; no provider child is spawned by this release.', + runUnavailableReason: hostTrusted ? null : 'Host-trusted local execution is disabled; set CURSOR_LOCAL_CLI_ENABLE_HOST_TRUSTED_RUNS=1 in the administrator MCP environment to expose it.', readOnlyDefault: true, - implementDeferredUntilHostAcceptance: true, + implementExplicitOnly: true, genericAgentAliasAccepted: false, cloudLedgerShared: false, }, @@ -856,35 +1493,81 @@ export class CursorLocalService { } async verifyRunEnvironment(value) { + if (!hostTrustedRunsEnabled(this.env)) { + throw new LocalRuntimeError('host_trusted_disabled', 'Host-trusted local execution is disabled by the administrator.'); + } const workspace = await allowedWorkspace(value.workspace, this.env); const binary = await this.binaryStatus(); if (!binary.available) throw new LocalRuntimeError('binary_unavailable', binary.reason ?? 'Cursor CLI binary is unavailable.'); - if (!binary.digestConfigured) throw new LocalRuntimeError('binary_digest_unpinned', 'CURSOR_LOCAL_CLI_SHA256 must pin the local Cursor CLI binary before a run.'); if (binary.drift) throw new LocalRuntimeError('binary_drift', 'The local Cursor CLI binary digest differs from the administrator pin.'); + const configuredConfigDir = nonEmpty(this.env.CURSOR_LOCAL_CLI_CONFIG_DIR); + if (configuredConfigDir && !path.isAbsolute(configuredConfigDir)) { + throw new LocalRuntimeError('invalid_configuration', 'CURSOR_LOCAL_CLI_CONFIG_DIR must be an absolute path; it will not fall back to HOME.'); + } const config = await inspectPermissionConfig(this.env, workspace); permissionReady(config, value.mode); - const home = nonEmpty(this.env.CURSOR_LOCAL_CLI_HOME); - if (!home || !path.isAbsolute(home)) throw new LocalRuntimeError('isolated_home_required', 'CURSOR_LOCAL_CLI_HOME must be an absolute owner-only directory for local runs.'); - await ownerOnlyPath(home, 'CURSOR_LOCAL_CLI_HOME'); + const home = nonEmpty(this.env.CURSOR_LOCAL_CLI_HOME) ?? nonEmpty(this.env.HOME); + if (!home || !path.isAbsolute(home)) throw new LocalRuntimeError('local_home_required', 'CURSOR_LOCAL_CLI_HOME or HOME must be an absolute directory for host-trusted runs.'); + const workspaceAttestation = await attestDirectory(workspace, 'Cursor workspace'); + const homeAttestation = await attestDirectory(home, 'Cursor local CLI home', { ownerOnly: true }); const configDir = configDirectory(this.env); - if (!nonEmpty(this.env.CURSOR_LOCAL_CLI_CONFIG_DIR) || !configDir || !path.isAbsolute(configDir)) throw new LocalRuntimeError('invalid_configuration', 'CURSOR_LOCAL_CLI_CONFIG_DIR must be explicitly configured as an absolute directory.'); - await ownerOnlyPath(configDir, 'CURSOR_LOCAL_CLI_CONFIG_DIR'); - const sandbox = await nativeSandboxStatus(this.env, this.execFileImpl); - if (!sandbox.ready) throw new LocalRuntimeError('sandbox_unavailable', sandbox.reason ?? 'A passing native sandbox preflight is required before a local run.'); - return { workspace, binary, config, home, configDir, sandbox }; + if (!configDir || !path.isAbsolute(configDir)) throw new LocalRuntimeError('invalid_configuration', 'CURSOR_LOCAL_CLI_CONFIG_DIR or HOME must resolve to an absolute Cursor config directory.'); + const configAttestation = await attestDirectory(configDir, 'Cursor local CLI config directory', { ownerOnly: true }); + return { + workspace: workspaceAttestation.path, + workspaceIdentity: workspaceAttestation.identity, + binary, + config, + home: homeAttestation.path, + homeIdentity: homeAttestation.identity, + configDir: configAttestation.path, + configIdentity: configAttestation.identity, + boundary: 'host_trusted', + }; + } + + async revalidateRunEnvironment(value, environment) { + const workspace = await allowedWorkspace(value.workspace, this.env); + const workspaceAttestation = await attestDirectory(workspace, 'Cursor workspace'); + const homeValue = nonEmpty(this.env.CURSOR_LOCAL_CLI_HOME) ?? nonEmpty(this.env.HOME); + const configuredConfigDir = nonEmpty(this.env.CURSOR_LOCAL_CLI_CONFIG_DIR); + if (configuredConfigDir && !path.isAbsolute(configuredConfigDir)) { + throw new LocalRuntimeError('environment_changed', 'CURSOR_LOCAL_CLI_CONFIG_DIR became relative before spawn.'); + } + const configValue = configDirectory(this.env); + const homeAttestation = await attestDirectory(homeValue, 'Cursor local CLI home', { ownerOnly: true }); + const configAttestation = await attestDirectory(configValue, 'Cursor local CLI config directory', { ownerOnly: true }); + const binary = await this.binaryStatus(); + if (!binary.available || binary.path !== environment.binary.path + || !sameFileIdentity(binary.identity, environment.binary.identity) + || binary.sha256 !== environment.binary.sha256) { + throw new LocalRuntimeError('environment_changed', 'The local execution environment changed before spawn.'); + } + const config = await inspectPermissionConfig(this.env, workspaceAttestation.path); + permissionReady(config, value.mode); + if (config.digest !== environment.config.digest + || workspaceAttestation.path !== environment.workspace + || !sameFileIdentity(workspaceAttestation.identity, environment.workspaceIdentity) + || homeAttestation.path !== environment.home + || !sameFileIdentity(homeAttestation.identity, environment.homeIdentity) + || configAttestation.path !== environment.configDir + || !sameFileIdentity(configAttestation.identity, environment.configIdentity)) { + throw new LocalRuntimeError('environment_changed', 'The local execution environment changed before spawn.'); + } } async run(value) { - throw new LocalRuntimeError('foundation_not_exposed', 'Local provider execution is deferred pending real host acceptance; this MCP release never spawns Cursor.'); - /* c8 ignore next -- retained foundation code is unreachable by design. */ + await this.reconcilePersistedRuns(); const environment = await this.verifyRunEnvironment(value); + const ownerStart = await this.ownerStartPromise; + if (ownerStart === null) throw new LocalRuntimeError('process_identity_unavailable', 'The MCP process start identity could not be attested.'); const readiness = await this.ledger.ensure(); const timeoutMs = value.timeoutMs ?? DEFAULT_TIMEOUT_MS; const waitMs = value.waitMs ?? DEFAULT_WAIT_MS; const maxEvents = value.maxEvents ?? DEFAULT_MAX_EVENTS; const maxBytes = value.maxBytes ?? DEFAULT_MAX_BYTES; const promptDigest = digest(value.prompt); - const requestDigest = digest({ kind: 'local-cli-run', workspace: environment.workspace, mode: value.mode, model: value.model ?? null, promptDigest }); + const requestDigest = digest({ kind: 'local-cli-run', executionProfile: value.execution_profile, workspace: environment.workspace, mode: value.mode, model: value.model ?? null, promptDigest }); const existing = await this.ledger.findRequest(value.requestId); if (existing) { if (existing.requestDigest !== requestDigest) throw new LocalRuntimeError('request_conflict', 'The local requestId was already used for a different request.'); @@ -892,7 +1575,7 @@ export class CursorLocalService { } const localId = `lrun-${randomUUID()}`; const worktreeName = `cursor-local-${localId.slice(5, 21)}`; - const args = buildArguments({ workspace: environment.workspace, prompt: value.prompt, mode: value.mode, model: value.model, worktreeName }); + const args = buildArguments({ workspace: environment.workspace, prompt: value.prompt, mode: value.mode, model: value.model, worktreeName, executionProfile: value.execution_profile }); const startedAt = new Date().toISOString(); const record = { localRunId: localId, @@ -904,7 +1587,21 @@ export class CursorLocalService { terminalState: null, mode: value.mode, workspace: environment.workspace, - execution: { strategy: 'cursor-cli-worktree', worktreeName, cwd: null }, + execution: { + strategy: 'cursor-cli-direct', + executionProfile: 'host_trusted', + boundary: 'host_trusted', + authority: 'mcp_process_user', + outerSandbox: 'none', + providerSandbox: 'disabled', + ownerPid: process.pid, + ownerStart, + childPid: null, + childStart: null, + processGroupId: null, + worktreeName, + cwd: null, + }, binary: { path: environment.binary.path, version: environment.binary.version ?? null, sha256: environment.binary.sha256 ?? null }, permissionProfile: value.mode, auth: { method: typeof this.env.CURSOR_LOCAL_CLI_API_KEY === 'string' && this.env.CURSOR_LOCAL_CLI_API_KEY ? 'api_key_env' : 'browser_or_unknown' }, @@ -915,28 +1612,75 @@ export class CursorLocalService { exitCode: null, signal: null, workspaceChanged: null, - workspaceChangeProof: 'native-sandbox-readonly-target', - sandbox: { path: environment.sandbox.path, sha256: environment.sandbox.sha256 }, + workspaceChangeProof: 'not_attested_host_trusted', + sandbox: { outer: 'none', provider: 'disabled' }, logs: { format: 'stream-json', events: [], bytes: 0, truncated: false }, }; - await this.ledger.add(record); + const persisted = await this.ledger.add(record); + if (persisted?.localRunId !== localId) { + return { ok: true, receipt: { ...publicRecord(persisted, { maxEvents, maxBytes, secrets: this.secrets() }), duplicate: true } }; + } + try { + await this.revalidateRunEnvironment(value, environment); + } catch (error) { + record.lifecycle = 'terminal'; + record.terminalState = 'environment_blocked'; + record.error = error?.code ?? 'environment_changed'; + record.finishedAt = new Date().toISOString(); + record.durationMs = 0; + await this.ledger.update(localId, () => ({ ...record })); + throw error; + } const childEnv = childEnvironment(this.env, { home: environment.home, configDir: environment.configDir }); - const sandboxArgs = buildSandboxArguments({ - sandboxPath: environment.sandbox.path, - home: environment.home, - configDir: environment.configDir, - workspace: environment.workspace, - binaryPath: environment.binary.path, - cursorArguments: args, - }); - const child = this.spawnImpl(environment.sandbox.path, sandboxArgs, { - cwd: '/', - env: childEnv, - detached: process.platform !== 'win32', - stdio: ['ignore', 'pipe', 'pipe'], - }); - const runtime = { record, child, startedAtMs: Date.now(), cancelRequested: false, timeoutHandle: null, done: null, systemEventSeen: false }; + let child; + try { + child = this.spawnImpl(environment.binary.path, args, { + cwd: environment.workspace, + env: childEnv, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + record.lifecycle = 'terminal'; + record.terminalState = 'failed'; + record.error = error?.code ?? 'spawn_error'; + record.finishedAt = new Date().toISOString(); + record.durationMs = 0; + await this.ledger.update(localId, () => ({ ...record })); + throw new LocalRuntimeError('spawn_failed', 'Unable to start the local Cursor CLI process.'); + } + record.execution.childPid = Number.isSafeInteger(child?.pid) ? child.pid : null; + // Attach stream/exit handlers before any asynchronous procfs lookup: a + // short-lived CLI can emit and close during that lookup. + record.execution.childStart = record.execution.childPid === null ? null : 'unknown'; + record.execution.processGroupId = record.execution.childPid; + // Read the token synchronously in the spawn return path as well as via the + // async fallback. A CLI can finish before an awaited procfs read runs; + // the immediate descriptor identity is what lets us both persist and + // safely reap such short-lived launches. + const immediateChildStart = record.execution.childPid === null ? null : processStartTokenSync(record.execution.childPid); + const childStartPromise = record.execution.childPid === null || immediateChildStart !== null + ? Promise.resolve(immediateChildStart) + : processStartToken(record.execution.childPid); + let resolveLaunchReady; + const launchReady = new Promise((resolve) => { resolveLaunchReady = resolve; }); + const runtime = { + record, + child, + startedAtMs: Date.now(), + cancelRequested: false, + timeoutHandle: null, + done: null, + systemEventSeen: false, + launchReady, + childStart: null, + identityAttested: false, + }; this.active.set(localId, runtime); + const cleanupProcessGroup = (options = {}) => terminateProcessGroup(child, { + ...options, + startToken: runtime.childStart, + }); const collector = createNdjsonCollector({ maxEvents, maxBytes, secrets: this.secrets(), onEvent: (event) => { if (event.type === 'system') runtime.systemEventSeen = true; if (event.cwd) { @@ -946,7 +1690,7 @@ export class CursorLocalService { : isPathWithin(environment.workspace, event.cwd) || isPathWithin(worktreeRoot, event.cwd); if (!allowedCwd) { runtime.environmentBlocked = true; - try { processKill(child); } catch {} + void cleanupProcessGroup().catch(() => {}); } } if (event.cwd) record.execution.cwd = event.cwd; @@ -957,57 +1701,128 @@ export class CursorLocalService { if (event.type === 'result') record.result = event.result ?? null; void this.ledger.update(localId, (entry) => ({ ...entry, lifecycle: 'working', logs: record.logs, execution: record.execution, result: record.result ?? null })); } }); - record.lifecycle = 'started'; - void this.ledger.update(localId, (entry) => ({ ...entry, lifecycle: 'started' })); + const stderrCollector = createStrictTextCollector({ maxBytes }); child.stdout?.on('data', (chunk) => collector.push(chunk)); - child.stderr?.on('data', (chunk) => { - const valueText = redactText(chunk.toString('utf8'), this.secrets()); - if (record.logs.events.length < maxEvents && record.logs.bytes < maxBytes) record.logs.events.push({ type: 'stderr', text: valueText }); - else record.logs.truncated = true; - }); + child.stderr?.on('data', (chunk) => stderrCollector.push(chunk)); runtime.done = new Promise((resolve) => { const finish = async (code, signal) => { - if (runtime.finished) return; + if (runtime.finishPromise) return runtime.finishPromise; runtime.finished = true; - if (runtime.timeoutHandle) clearTimeout(runtime.timeoutHandle); - const collected = collector.finish(); - record.logs.events = collected.events; - record.logs.bytes = collected.bytes; - record.logs.truncated ||= collected.truncated; - record.exitCode = Number.isInteger(code) ? code : null; - record.signal = signal ?? null; - record.finishedAt = new Date().toISOString(); - record.durationMs = Date.now() - runtime.startedAtMs; - const expectedWorktreeRoot = path.join(path.resolve(environment.home), '.cursor', 'worktrees'); - if (!runtime.systemEventSeen || (record.mode === 'implement' && !isPathWithin(expectedWorktreeRoot, record.execution.cwd))) { - runtime.environmentBlocked = true; - } - record.workspaceChanged = runtime.environmentBlocked ? null : false; - record.lifecycle = 'terminal'; - record.terminalState = runtime.environmentBlocked ? 'environment_blocked' - : runtime.cancelRequested ? 'cancelled' - : runtime.timedOut ? 'timed_out' - : code === 0 ? 'succeeded' : 'failed'; - if (record.mode === 'read_only' && record.workspaceChanged) record.terminalState = 'workspace_changed'; - await this.ledger.update(localId, () => ({ ...record })); - this.active.delete(localId); - resolve(); + runtime.finishPromise = (async () => { + try { + // If close/error raced the procfs lookup, wait until launch + // ownership has either been durably recorded or failed closed. + await runtime.launchReady; + if (runtime.timeoutHandle) clearTimeout(runtime.timeoutHandle); + // `close` describes the leader, not necessarily every member of + // its detached process group. Reap descendants after leader exit + // only with the exact start token captured for this launch. + try { await cleanupProcessGroup({ graceMs: 250 }); } catch { runtime.descendantCleanupFailed = true; } + const collected = collector.finish(); + const stderr = stderrCollector.finish(); + const stderrEvent = stderr.bytes > 0 || stderr.invalidUtf8 + ? { type: 'stderr', text: redactText(stderr.text, this.secrets()), ...(stderr.invalidUtf8 ? { invalidUtf8: true } : {}) } + : null; + const bounded = boundEvents( + [...collected.events, ...(stderrEvent ? [stderrEvent] : [])], + { maxEvents, maxBytes, secrets: this.secrets() }, + ); + record.logs.events = bounded.events; + record.logs.bytes = bounded.bytes; + record.logs.truncated ||= collected.truncated || stderr.truncated || bounded.truncated; + record.logs.invalidUtf8 ||= collected.invalidUtf8 || stderr.invalidUtf8; + record.exitCode = Number.isInteger(code) ? code : null; + record.signal = signal ?? null; + record.finishedAt = new Date().toISOString(); + record.durationMs = Date.now() - runtime.startedAtMs; + const expectedWorktreeRoot = path.join(path.resolve(environment.home), '.cursor', 'worktrees'); + const cwdAttested = record.execution.cwd && (record.mode === 'implement' + ? isPathWithin(expectedWorktreeRoot, record.execution.cwd) + : isPathWithin(environment.workspace, record.execution.cwd) || isPathWithin(expectedWorktreeRoot, record.execution.cwd)); + if (!runtime.systemEventSeen || !cwdAttested) { + runtime.environmentBlocked = true; + } + // A direct host-trusted process has no outer filesystem observer. + // Do not report a clean workspace as proof merely because Cursor + // exited. + record.workspaceChanged = null; + record.lifecycle = 'terminal'; + record.terminalState = runtime.descendantCleanupFailed || runtime.identityUnavailable || runtime.launchPersistenceFailed ? 'transport_lost' + : runtime.environmentBlocked ? 'environment_blocked' + : runtime.cancelRequested ? 'cancelled' + : runtime.timedOut ? 'timed_out' + : code === 0 ? 'succeeded' : 'failed'; + if (record.mode === 'read_only' && record.workspaceChanged) record.terminalState = 'workspace_changed'; + await this.ledger.update(localId, () => ({ ...record })); + } catch (error) { + // A ledger/cleanup failure must not strand runtime.done forever or + // leave an accepted/working receipt pretending to be durable. + record.lifecycle = 'terminal'; + record.terminalState = 'transport_lost'; + record.error ||= error?.code ?? 'local_runtime_failed'; + record.finishedAt ||= new Date().toISOString(); + record.durationMs ??= Date.now() - runtime.startedAtMs; + await this.ledger.update(localId, () => ({ ...record })).catch(() => {}); + } finally { + this.active.delete(localId); + resolve(); + } + })(); + return runtime.finishPromise; }; - child.once?.('error', (error) => { record.error = error.code ?? 'spawn_error'; finish(null, null); }); - child.once?.('close', finish); + child.once?.('error', (error) => { record.error = error.code ?? 'spawn_error'; void finish(null, null); }); + child.once?.('close', (...args) => { void finish(...args); }); runtime.timeoutHandle = setTimeout(() => { runtime.timedOut = true; - try { processKill(child); } catch {} + void cleanupProcessGroup().catch(() => {}); }, timeoutMs); }); + + // Capture and durably persist the child start identity before this method + // can return. A process that exits before procfs can be read is marked + // transport_lost; its unknown PID/group is intentionally never signalled. + let launchError = null; + try { + runtime.childStart = await childStartPromise; + record.execution.childStart = runtime.childStart ?? 'unknown'; + runtime.identityAttested = runtime.childStart !== null; + if (!runtime.identityAttested) runtime.identityUnavailable = true; + const launched = await this.ledger.update(localId, (entry) => ({ + ...entry, + lifecycle: 'started', + execution: { ...entry.execution, childPid: record.execution.childPid, childStart: record.execution.childStart, processGroupId: record.execution.processGroupId }, + })); + if (!launched) throw new LocalRuntimeError('launch_persist_failed', 'The local launch ownership record disappeared before spawn completed.'); + if (runtime.identityUnavailable) { + record.lifecycle = 'terminal'; + record.terminalState = 'transport_lost'; + record.error = 'process_identity_unavailable'; + record.finishedAt = new Date().toISOString(); + record.durationMs = Date.now() - runtime.startedAtMs; + await this.ledger.update(localId, () => ({ ...record })); + launchError = new LocalRuntimeError('process_identity_unavailable', 'The local child start identity could not be attested.'); + } + } catch (error) { + runtime.launchPersistenceFailed = true; + record.error ||= error?.code ?? 'launch_persist_failed'; + launchError = error instanceof LocalRuntimeError + ? error + : new LocalRuntimeError('launch_persist_failed', 'Unable to durably record local launch ownership.'); + if (runtime.identityAttested) await cleanupProcessGroup().catch(() => {}); + } finally { + resolveLaunchReady(); + } + if (launchError) { + await Promise.race([runtime.done, sleep(5_000)]); + throw launchError; + } await Promise.race([runtime.done, sleep(waitMs)]); const current = await this.ledger.find(localId); return { ok: true, receipt: publicRecord(current ?? record, { maxEvents, maxBytes, secrets: this.secrets() }) }; } async runs(value) { - throw new LocalRuntimeError('foundation_not_exposed', 'Local process lifecycle is deferred pending real host acceptance; this MCP release never adopts or cancels local processes.'); - /* c8 ignore next -- retained foundation code is unreachable by design. */ + await this.reconcilePersistedRuns(); const current = await this.ledger.find(value.localRunId); if (!current) throw new LocalRuntimeError('not_found', `Unknown local run ${value.localRunId}.`); if (value.action === 'get') return { ok: true, run: publicRecord(current, { secrets: this.secrets() }) }; @@ -1015,15 +1830,26 @@ export class CursorLocalService { const runtime = this.active.get(value.localRunId); if (!runtime) throw new LocalRuntimeError('not_running', 'The local run is not owned by this MCP process and cannot be cancelled.'); runtime.cancelRequested = true; - processKill(runtime.child); + await terminateProcessGroup(runtime.child, { startToken: runtime.childStart }); await Promise.race([runtime.done, sleep(5_000)]); const updated = await this.ledger.find(value.localRunId); return { ok: true, cancelled: true, run: publicRecord(updated ?? current, { secrets: this.secrets() }) }; } + async shutdown() { + const runtimes = [...this.active.values()]; + for (const runtime of runtimes) { + runtime.cancelRequested = true; + await terminateProcessGroup(runtime.child, { startToken: runtime.childStart }).catch(() => {}); + } + await Promise.all(runtimes.map((runtime) => Promise.race([runtime.done, sleep(5_000)]))); + } + async call(name, rawArguments) { if (!['status', 'run', 'runs'].includes(name)) throw new LocalInputError('unknown_tool', `Unknown local tool ${name}.`); - if (name !== 'status') throw new LocalRuntimeError('foundation_not_exposed', 'Local run and lifecycle tools are deferred pending real host acceptance; use status for this release.'); + if (name !== 'status' && !hostTrustedRunsEnabled(this.env)) { + throw new LocalRuntimeError('foundation_not_exposed', 'Host-trusted local execution is disabled; use status or enable CURSOR_LOCAL_CLI_ENABLE_HOST_TRUSTED_RUNS=1 in the administrator MCP environment.'); + } const value = validateToolInput(name, rawArguments ?? {}); if (name === 'status') return this.status(value); if (name === 'run') return this.run(value); @@ -1049,33 +1875,37 @@ export async function handleToolCall(name, rawArguments, service = new CursorLoc export async function runStdio({ input = process.stdin, output = process.stdout, service = new CursorLocalService() } = {}) { const lines = readline.createInterface({ input, crlfDelay: Infinity }); - for await (const line of lines) { - if (!line.trim()) continue; - let message; - try { message = JSON.parse(line); } catch { - output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Invalid JSON.' } })}\n`); - continue; - } - if (message.method?.startsWith('notifications/')) continue; - if (message.method === 'initialize') { - const requested = message.params?.protocolVersion; - const negotiated = SUPPORTED_MCP_PROTOCOL_VERSIONS.includes(requested) ? requested : MCP_PROTOCOL_VERSION; - output.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { - protocolVersion: negotiated, - capabilities: { tools: { listChanged: false } }, - serverInfo: SERVER_IDENTITY, - instructions: 'Cursor Local Control invokes only the administrator-selected local Cursor CLI. Local IDs, state, logs, credentials, and permissions are separate from Cursor Cloud Control.', - } })}\n`); - continue; - } - if (message.method === 'ping') { output.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: {} })}\n`); continue; } - if (message.method === 'tools/list') { output.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { tools: TOOLS } })}\n`); continue; } - if (message.method === 'tools/call') { - const result = await handleToolCall(message.params?.name, message.params?.arguments ?? {}, service); - output.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result })}\n`); - continue; + try { + for await (const line of lines) { + if (!line.trim()) continue; + let message; + try { message = JSON.parse(line); } catch { + output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Invalid JSON.' } })}\n`); + continue; + } + if (message.method?.startsWith('notifications/')) continue; + if (message.method === 'initialize') { + const requested = message.params?.protocolVersion; + const negotiated = SUPPORTED_MCP_PROTOCOL_VERSIONS.includes(requested) ? requested : MCP_PROTOCOL_VERSION; + output.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { + protocolVersion: negotiated, + capabilities: { tools: { listChanged: false } }, + serverInfo: SERVER_IDENTITY, + instructions: 'Cursor Local Control invokes only the administrator-selected local Cursor CLI. Host-trusted runs use the MCP process user with no outer sandbox; local IDs, state, logs, credentials, and permissions are separate from Cursor Cloud Control.', + } })}\n`); + continue; + } + if (message.method === 'ping') { output.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: {} })}\n`); continue; } + if (message.method === 'tools/list') { output.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { tools: service.tools() } })}\n`); continue; } + if (message.method === 'tools/call') { + const result = await handleToolCall(message.params?.name, message.params?.arguments ?? {}, service); + output.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result })}\n`); + continue; + } + output.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: `Method ${message.method ?? 'unknown'} not found.` } })}\n`); } - output.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: `Method ${message.method ?? 'unknown'} not found.` } })}\n`); + } finally { + await service.shutdown?.(); } } diff --git a/plugins/cursor-cloud-control/mcp/server.mjs b/plugins/cursor-cloud-control/mcp/server.mjs index 1058165..b857210 100644 --- a/plugins/cursor-cloud-control/mcp/server.mjs +++ b/plugins/cursor-cloud-control/mcp/server.mjs @@ -1,11 +1,16 @@ #!/usr/bin/env node -import { randomUUID } from 'node:crypto'; -import { lstat, readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import readline from 'node:readline'; -import { CursorApiClient, CursorApiError, DEFAULT_API_ORIGIN, defaultApiKeyFile, loadApiKey } from './client.mjs'; +import { + CursorApiClient, + CursorApiError, + DEFAULT_API_ORIGIN, + defaultApiKeyFile, + loadApiKey, + readOwnerOnlyFile, +} from './client.mjs'; import { saveArtifact, maxArtifactBytes } from './artifacts.mjs'; import { SubmissionLedger, requestDigest, resolveStateDirectory } from './ledger.mjs'; import { redactError, redactValue } from './redaction.mjs'; @@ -22,12 +27,12 @@ import { export const MCP_PROTOCOL_VERSION = '2025-11-25'; export const SUPPORTED_MCP_PROTOCOL_VERSIONS = Object.freeze(['2025-11-25', '2024-11-05']); -export const SERVER_IDENTITY = Object.freeze({ name: 'cursor-cloud-control', version: '0.3.0' }); +export const SERVER_IDENTITY = Object.freeze({ name: 'cursor-cloud-control', version: '0.4.0' }); const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const TOOL_DESCRIPTIONS = Object.freeze({ status: 'Show local Cursor Cloud Control configuration, or perform one safe read-only compact identity/models/repositories discovery action.', - agents: 'List, inspect, or create typed Cursor Cloud Agents. Creation defaults to plan mode, a new branch, and no pull request.', + agents: 'List, inspect, create, or explicitly reconcile typed Cursor Cloud Agents. Creation defaults to plan mode, a new branch, and no pull request.', runs: 'List, inspect, follow up, wait for, stream, or cancel one exact Cursor Cloud Agent run.', artifacts: 'List agent artifacts or download one exact artifact to an administrator-configured owner-only local root.', usage: 'Read token usage for one exact Cursor Cloud Agent, optionally scoped to one run.', @@ -88,6 +93,9 @@ export const TOOLS = Object.freeze([ ]); const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); +const RECONCILIATION_404_ATTEMPTS = 2; +const RECONCILIATION_404_BACKOFF_MS = 100; +const RUN_NOT_FOUND_CONFIRMATIONS_REQUIRED = 2; function artifactRootConfigured(env = process.env) { const value = typeof env.CURSOR_ARTIFACT_ROOT === 'string' ? env.CURSOR_ARTIFACT_ROOT.trim() : ''; @@ -115,6 +123,57 @@ function requestedCreateConfiguration(value) { }; } +function createReconciliationHints(value) { + const repositories = value.repos?.map((repo) => requestDigest('repository-url', repo.url)) ?? []; + return { + nameDigest: value.name ? requestDigest('agent-name', value.name) : null, + promptDigest: requestDigest('agent-prompt', value.prompt.text), + modelId: value.model?.id ?? null, + repositoryDigests: repositories, + mode: value.mode ?? 'plan', + }; +} + +function agentPromptText(agent) { + if (typeof agent?.prompt === 'string') return agent.prompt; + if (typeof agent?.prompt?.text === 'string') return agent.prompt.text; + return null; +} + +function agentModelId(agent) { + if (typeof agent?.model === 'string') return agent.model; + return typeof agent?.model?.id === 'string' ? agent.model.id : null; +} + +function agentRepositories(agent) { + const repositories = agent?.repos ?? agent?.repositories; + if (!Array.isArray(repositories)) return null; + return repositories.map((repo) => typeof repo === 'string' ? repo : repo?.url).filter((url) => typeof url === 'string'); +} + +function providerAgentMatchesHints(agent, hints) { + if (!agent || typeof agent !== 'object' || typeof agent.id !== 'string' || !hints) return false; + if (hints.nameDigest !== null) { + if (typeof agent.name !== 'string' || requestDigest('agent-name', agent.name) !== hints.nameDigest) return false; + } + if (hints.promptDigest !== null) { + const prompt = agentPromptText(agent); + if (prompt === null || requestDigest('agent-prompt', prompt) !== hints.promptDigest) return false; + } + if (hints.modelId !== null) { + if (agentModelId(agent) !== hints.modelId) return false; + } + if (hints.repositoryDigests?.length > 0) { + const repositories = agentRepositories(agent); + if (!repositories || repositories.length !== hints.repositoryDigests.length) return false; + const digests = repositories.map((url) => requestDigest('repository-url', url)); + if (digests.some((digest, index) => digest !== hints.repositoryDigests[index])) return false; + } + if (hints.mode !== null && agent.mode !== undefined && agent.mode !== hints.mode) return false; + // A fingerprint with no provider-visible fields is not evidence of identity. + return hints.nameDigest !== null || hints.promptDigest !== null || hints.modelId !== null || hints.repositoryDigests?.length > 0; +} + function legacyEffectiveCreateConfiguration(value) { return { ...requestedCreateConfiguration(value), @@ -242,6 +301,22 @@ function successResult(payload) { return { ok: true, ...payload }; } +function derivedRequestId(kind, value) { + return `auto-${requestDigest(kind, value).slice(0, 56)}`; +} + +function mutationReceipt(requestId, digest, record, extra = {}) { + return { + requestId, + requestDigest: digest, + duplicate: false, + status: record?.status ?? 'completed', + agentId: record?.agentId ?? null, + runId: record?.runId ?? null, + ...extra, + }; +} + export class CursorCloudService { constructor({ env = process.env, client, ledger, fetchImpl } = {}) { this.env = env; @@ -292,12 +367,7 @@ export class CursorCloudService { const fileName = typeof this.env.CURSOR_API_KEY_FILE === 'string' && this.env.CURSOR_API_KEY_FILE.trim() ? this.env.CURSOR_API_KEY_FILE.trim() : defaultApiKeyFile(this.env); - try { - const metadata = await lstat(fileName); - if (metadata.isFile() && !metadata.isSymbolicLink() && (metadata.mode & 0o077) === 0) { - configuredFile = Boolean((await readFile(fileName, 'utf8')).trim()); - } - } catch {} + try { configuredFile = Boolean(await readOwnerOnlyFile(fileName, { emptyIsMissing: true })); } catch {} } const configured = configuredByEnvironment || configuredFile; const readiness = await this.ledger.readiness(); @@ -361,20 +431,30 @@ export class CursorCloudService { const client = await this.getClient(); return successResult({ agent: redactValue(await client.getAgent(value.agentId), this.secrets(operation)) }); } + if (value.action === 'reconcile') return this.reconcileAgent(value, operation); const input = withMaterializedMcpServers(value, this.env, (values) => addOperationSecrets(operation, values)); const client = await this.getClient(); const requestId = value.requestId; - const previous = await this.ledger.lookup(requestId); - const agentId = value.envVars !== undefined - ? undefined - : (value.agentId ?? previous?.agentId ?? `bc-${randomUUID()}`); - const body = mapCreateBody(input, agentId); - // The generated agent ID is a submission detail, not caller intent. Keep - // it out of the request digest so concurrent identical requests share one - // idempotency key; an explicit caller-supplied ID remains part of intent. + // Cursor assigns an ID when the caller omits agentId. Keep the local + // reservation provider-neutral and only forward an ID that the caller + // explicitly supplied. + const agentId = value.agentId ?? null; + const body = mapCreateBody(input, value.agentId); + // The provider ID is caller intent only when explicitly supplied; the + // stable request ID remains the local idempotency key for omitted IDs. const digest = requestDigest('create-agent', mapCreateBody(value, value.agentId)); - const began = await this.ledger.begin({ requestId, kind: 'create-agent', digest, agentId }); + const reconciliationHints = createReconciliationHints(value); + const reconciliationFingerprint = requestDigest('create-agent-reconciliation', reconciliationHints); + const began = await this.ledger.begin({ + requestId, + kind: 'create-agent', + digest, + agentId, + providerAgentId: value.agentId ?? null, + reconciliationFingerprint, + reconciliationHints, + }); if (began.duplicate) { return successResult({ receipt: { requestId, @@ -392,18 +472,83 @@ export class CursorCloudService { try { response = await client.createAgent(body); } catch (error) { - if (isAmbiguous(error) || error?.code === 'conflict') { - const fields = submissionFields(agentId); + if (isAmbiguous(error)) { + const fields = { + ...submissionFields(value.agentId ?? null), + ...providerErrorField(error), + reconciliationHints, + reconciliationFingerprint, + }; await bestEffortUncertain(this.ledger, requestId, fields, { kind: 'create-agent', digest }); const reconciliation = fields.agentId ? `agent ID ${fields.agentId}` : 'agent listing and the request ledger'; throw uncertainSubmissionError(`Cursor may have accepted the create request but the response was not confirmed; reconcile via ${reconciliation} before retrying.`, fields); } - await this.ledger.fail(requestId, { agentId: opaqueSubmissionId(agentId), failureCode: error?.code ?? 'submission_failed' }); - throw error; + await this.ledger.fail(requestId, { + agentId: opaqueSubmissionId(agentId), + failureCode: error?.code ?? 'submission_failed', + ...providerErrorField(error), + }); + throw withProviderCode(error); } const agent = response?.agent ?? null; const run = response?.run ?? null; - const finalized = submissionFields(agent?.id ?? agentId, run?.id ?? null); + // A caller-supplied ID is an intent constraint, not proof that Cursor + // accepted the create. Finalize only when the 2xx response itself carries + // an opaque provider agent ID; an empty or malformed success response must + // remain uncertain even for explicit-ID creates. + const providerAgentId = opaqueSubmissionId(agent?.id ?? null); + if (!providerAgentId) { + const fields = { + ...submissionFields(null, run?.id ?? null), + reconciliationHints, + reconciliationFingerprint, + responseShape: 'create-2xx-without-agent-id', + }; + await bestEffortUncertain(this.ledger, requestId, fields, { kind: 'create-agent', digest }); + throw uncertainSubmissionError( + 'Cursor returned success without a provider agent ID; the create outcome is not safe to finalize or resubmit. Reconcile the bounded provider listing or explicitly release this reservation.', + fields, + ); + } + if (value.agentId !== undefined && providerAgentId !== value.agentId) { + const fields = { + ...submissionFields(null, run?.id ?? null), + providerAgentId: value.agentId, + providerReturnedAgentId: providerAgentId, + responseShape: 'create-provider-agent-id-mismatch', + reconciliationHints, + reconciliationFingerprint, + }; + await bestEffortUncertain(this.ledger, requestId, fields, { kind: 'create-agent', digest }); + throw uncertainSubmissionError( + 'Cursor returned a provider agent ID different from the explicitly requested ID; the create outcome is not safe to finalize or resubmit. Reconcile the requested agent or explicitly release this reservation.', + fields, + ); + } + // A create response may include a run object in addition to the agent. + // The create endpoint is not allowed to attest a run belonging to some + // other agent (or a malformed run with no identity); keep the reservation + // uncertain until the exact provider association is proven. + const providerRunId = opaqueSubmissionId(run?.id ?? null); + if (run !== null && !exactProviderRunIdentity(run, providerAgentId, providerRunId)) { + const fields = { + ...submissionFields(providerAgentId, providerRunId), + providerAgentId, + ...providerRunIdentityFields(run), + responseShape: 'create-provider-run-identity-mismatch', + reconciliationHints, + reconciliationFingerprint, + }; + await bestEffortUncertain(this.ledger, requestId, fields, { kind: 'create-agent', digest }); + throw uncertainSubmissionError( + 'Cursor returned a provider run whose identity does not exactly match the created agent; the create outcome is not safe to finalize. Reconcile the recorded agent and run or explicitly release this reservation.', + fields, + ); + } + const finalized = { + ...submissionFields(providerAgentId, providerRunId), + providerAgentId, + }; try { await this.ledger.complete(requestId, finalized); } catch { @@ -430,6 +575,245 @@ export class CursorCloudService { }); } + async reconcileAgent(value, operation) { + await this.requireDurableState(); + const requestId = value.requestId; + const record = await this.ledger.lookup(requestId); + if (!record) { + throw new CursorApiError('ledger_record_missing', 'No durable submission reservation exists for this request ID.'); + } + if (record.kind !== 'create-agent') { + throw new CursorApiError('reconciliation_not_supported', 'This reconciliation path is only available for uncertain agent creation reservations.'); + } + + // Reconciliation is intentionally idempotent after a local finalization. + // Do not make another provider call for a completed reservation, and only + // treat a failed record as already reconciled when it carries the exact + // provider-absence marker written below. + if (record.status === 'completed') { + return successResult({ + requestId, + reconciled: false, + alreadyFinalized: true, + status: record.status, + agentId: record.agentId, + runId: record.runId ?? null, + }); + } + if (record.status === 'failed') { + if (record.reconciliationReason !== 'provider_not_found') { + throw new CursorApiError('reconciliation_not_required', 'The submission does not require provider-absence reconciliation.'); + } + return successResult({ + requestId, + reconciled: true, + alreadyFinalized: true, + status: record.status, + agentId: record.agentId, + runId: record.runId ?? null, + }); + } + if (record.status === 'pending') { + throw new CursorApiError('submission_in_progress', 'The submission is still in progress; reconcile it only after transport uncertainty is recorded.'); + } + if (record.status !== 'uncertain') { + throw new CursorApiError('reconciliation_not_required', 'The submission does not require provider-absence reconciliation.'); + } + + if (value.release === true) { + if (value.confirmation !== `release:${requestId}`) { + throw new CursorApiError('confirmation_required', `Uncertain reservation release requires confirmation exactly equal to release:${requestId}.`); + } + const released = await this.ledger.release(requestId, { reason: 'operator_release' }); + return successResult({ + requestId, + reconciled: true, + alreadyFinalized: released.duplicate, + status: released.record.status, + agentId: released.record.agentId, + runId: released.record.runId ?? null, + provider: { agent: 'unknown', reservation: 'released', reason: 'operator_release' }, + }); + } + + // New records distinguish a caller-supplied provider ID from a local + // reservation ID. Legacy records lack providerAgentId and are treated as + // having used their stored agentId at Cursor. + const providerAgentId = Object.hasOwn(record, 'providerAgentId') ? record.providerAgentId : record.agentId; + const agentId = providerAgentId; + if (!agentId) { + const client = await this.getClient(); + const probe = await findProviderAssignedAgent(client, record); + // A listing fingerprint has no reservation-time provenance: an identical + // agent may have existed before this request. Even a unique match is + // therefore diagnostic evidence only, never proof that this create + // produced that agent. Keep the reservation uncertain and require an + // explicit operator release (or a provider contract with a creation + // receipt) rather than risking attribution of a pre-existing agent. + throw uncertainSubmissionError( + probe.state === 'found' + ? 'A provider agent matched the bounded fingerprint, but the listing has no reservation-time evidence proving this create produced it; the reservation remains uncertain.' + : probe.state === 'ambiguous' + ? 'Multiple provider agents matched the bounded reconciliation fingerprint; the reservation remains uncertain.' + : 'No unique provider agent matched the bounded reconciliation fingerprint. The reservation remains uncertain; explicitly release it only after accepting duplicate-risk.', + { reconciliationFingerprint: record.reconciliationFingerprint, providerListingMatch: probe.state }, + ); + } + if (value.agentId !== undefined && value.agentId !== agentId) { + throw new CursorApiError('reconciliation_target_mismatch', 'The provider agent ID does not match the uncertain reservation.'); + } + + const client = await this.getClient(); + const probe = await confirmProviderAgent(client, agentId); + if (probe.state === 'absent') { + const finalized = await this.ledger.reconcile(requestId, { agentId }); + return successResult({ + requestId, + reconciled: true, + alreadyFinalized: finalized.duplicate, + status: finalized.record.status, + agentId: finalized.record.agentId, + runId: finalized.record.runId ?? null, + provider: { agent: 'not_found', runs: 'not_found', reservation: 'released' }, + }); + } + if (probe.state !== 'found') { + throw uncertainSubmissionError( + 'Cursor agent lookup remained inconsistent after bounded repeated checks; the reservation remains uncertain.', + { ...submissionFields(agentId), ...providerAgentIdentityFields(probe.agent) }, + ); + } + const agent = probe.agent; + + // The provider returned an agent, so the original mutation did happen (or + // at least an agent with the reserved ID exists). Finalize as completed; + // never resubmit the create request. latestRunId is provider data and is + // kept only as an opaque receipt field. + const providerRunId = agent?.latestRunId ?? agent?.runId ?? null; + let finalized; + try { + finalized = await this.ledger.complete(requestId, { + ...submissionFields(agentId, providerRunId), + providerAgentId: agentId, + }); + } catch { + await bestEffortUncertain(this.ledger, requestId, submissionFields(agentId, providerRunId), { + kind: 'create-agent', + digest: record.digest, + }); + throw uncertainSubmissionError( + 'Cursor returned the reserved agent, but durable completion was not confirmed; reconcile the recorded agent again before retrying.', + submissionFields(agentId, providerRunId), + ); + } + return successResult({ + requestId, + reconciled: true, + alreadyFinalized: false, + status: finalized?.record?.status ?? 'completed', + agentId, + runId: providerRunId, + provider: { agent: 'found', reservation: 'completed' }, + }); + } + + async reconcileRun(value, operation) { + await this.requireDurableState(); + const requestId = value.requestId; + const record = await this.ledger.lookup(requestId); + if (!record) throw new CursorApiError('ledger_record_missing', 'No durable run reservation exists for this request ID.'); + if (!['followup-run', 'cancel-run'].includes(record.kind)) { + throw new CursorApiError('reconciliation_not_supported', 'This reconciliation path only supports follow-up and cancellation reservations.'); + } + if (record.status === 'completed') { + return successResult({ requestId, reconciled: false, alreadyFinalized: true, status: record.status, agentId: record.agentId, runId: record.runId ?? null }); + } + if (record.status === 'failed') { + if (!record.reconciliationReason) throw new CursorApiError('reconciliation_not_required', 'The run reservation does not require reconciliation.'); + return successResult({ requestId, reconciled: true, alreadyFinalized: true, status: record.status, agentId: record.agentId, runId: record.runId ?? null }); + } + if (record.status === 'pending') throw new CursorApiError('submission_in_progress', 'The run mutation is still in progress; reconcile it only after uncertainty is recorded.'); + if (record.status !== 'uncertain') throw new CursorApiError('reconciliation_not_required', 'The run reservation does not require reconciliation.'); + + if (value.release === true) { + if (value.confirmation !== `release:${requestId}`) throw new CursorApiError('confirmation_required', `Uncertain reservation release requires confirmation exactly equal to release:${requestId}.`); + const released = await this.ledger.release(requestId, { reason: 'operator_release' }); + return successResult({ requestId, reconciled: true, alreadyFinalized: released.duplicate, status: released.record.status, agentId: released.record.agentId, runId: released.record.runId ?? null, provider: { state: 'unknown', reservation: 'released', reason: 'operator_release' } }); + } + + const agentId = record.agentId; + if (!agentId || (value.agentId !== undefined && value.agentId !== agentId)) { + throw new CursorApiError('reconciliation_target_mismatch', 'The provider agent ID does not match the uncertain run reservation.'); + } + const storedRunId = record.runId ?? null; + const targetRunId = value.runId ?? storedRunId; + if (!targetRunId) { + throw new CursorApiError('reconciliation_target_missing', 'A provider run ID is required to reconcile this uncertain follow-up; explicitly release the reservation if provider state cannot be proven.'); + } + if (storedRunId && targetRunId !== storedRunId) { + throw new CursorApiError('reconciliation_target_mismatch', 'The provider run ID does not match the uncertain run reservation.'); + } + + const client = await this.getClient(); + let run; + try { + run = await client.getRun(agentId, targetRunId); + } catch (error) { + if (error?.code !== 'not_found' || error?.status !== 404) { + if (record.providerNotFoundConfirmations) { + await bestEffortUncertain(this.ledger, requestId, { providerNotFoundConfirmations: 0 }, { + kind: record.kind, + digest: record.digest, + }); + } + throw error; + } + const confirmations = Number.isInteger(record.providerNotFoundConfirmations) + ? record.providerNotFoundConfirmations : 0; + const nextConfirmations = Math.min(confirmations + 1, RUN_NOT_FOUND_CONFIRMATIONS_REQUIRED); + if (nextConfirmations < RUN_NOT_FOUND_CONFIRMATIONS_REQUIRED) { + const fields = { + ...submissionFields(agentId, targetRunId), + providerNotFoundConfirmations: nextConfirmations, + }; + await bestEffortUncertain(this.ledger, requestId, fields, { kind: record.kind, digest: record.digest }); + throw uncertainSubmissionError( + 'Cursor returned one exact 404 for the requested run; bounded repeated confirmation is required before releasing the uncertain reservation.', + fields, + ); + } + const released = await this.ledger.reconcile(requestId, { agentId }); + return successResult({ requestId, reconciled: true, alreadyFinalized: released.duplicate, status: released.record.status, agentId: released.record.agentId, runId: targetRunId, provider: { state: 'not_found', reservation: 'released' } }); + } + // A previous 404 is no longer a confirmation if any provider object is + // returned. Completion below clears the durable observation metadata; for + // an identity/status mismatch, reset it before retaining uncertainty so a + // later isolated 404 cannot release this reservation. + if (record.providerNotFoundConfirmations) { + await bestEffortUncertain(this.ledger, requestId, { providerNotFoundConfirmations: 0 }, { + kind: record.kind, + digest: record.digest, + }); + } + if (!exactProviderRunIdentity(run, agentId, targetRunId)) { + throw uncertainSubmissionError( + 'Cursor returned a run whose identity does not exactly match the requested agent and run; the reservation remains uncertain.', + { ...submissionFields(agentId, targetRunId), ...providerRunIdentityFields(run) }, + ); + } + if (record.kind === 'cancel-run') { + const status = typeof run.status === 'string' ? run.status.toUpperCase() : ''; + if (!['CANCELLED', 'CANCELED'].includes(status)) { + throw uncertainSubmissionError( + 'Cursor returned the targeted run, but its status does not confirm cancellation; the reservation remains uncertain.', + { ...submissionFields(agentId, targetRunId), providerStatus: run.status ?? null }, + ); + } + } + const finalized = await this.ledger.complete(requestId, submissionFields(agentId, targetRunId)); + return successResult({ requestId, reconciled: true, alreadyFinalized: finalized.duplicate, status: finalized.record.status, agentId, runId: targetRunId, provider: { state: 'found', reservation: 'completed' }, run: redactValue(run, this.secrets(operation)) }); + } + async runs(value, operation) { if (value.action === 'list') { const client = await this.getClient(); @@ -440,10 +824,57 @@ export class CursorCloudService { const client = await this.getClient(); return successResult({ run: redactValue(await client.getRun(value.agentId, value.runId), this.secrets(operation)) }); } + if (value.action === 'reconcile') return this.reconcileRun(value, operation); if (value.action === 'cancel') { await this.requireDurableState(); const client = await this.getClient(); - return successResult({ cancelled: redactValue(await client.cancelRun(value.agentId, value.runId), this.secrets(operation)), agentId: value.agentId, runId: value.runId }); + const requestId = value.requestId ?? derivedRequestId('cancel-run', { agentId: value.agentId, runId: value.runId }); + const digest = requestDigest('cancel-run', { agentId: value.agentId, runId: value.runId }); + const began = await this.ledger.begin({ requestId, kind: 'cancel-run', digest, agentId: value.agentId, runId: value.runId, providerAgentId: null }); + if (began.duplicate) { + return successResult({ + receipt: { requestId, requestDigest: digest, duplicate: true, status: began.record.status, agentId: began.record.agentId, runId: began.record.runId ?? value.runId }, + agentId: value.agentId, + runId: value.runId, + }); + } + let cancelled; + try { + cancelled = await client.cancelRun(value.agentId, value.runId); + } catch (error) { + if (isAmbiguous(error)) { + const fields = { ...submissionFields(value.agentId, value.runId), ...providerErrorField(error) }; + await bestEffortUncertain(this.ledger, requestId, fields, { kind: 'cancel-run', digest }); + throw uncertainSubmissionError('Cursor may have accepted the cancellation but the response was not confirmed; reconcile or explicitly release this reservation before retrying.', fields); + } + await this.ledger.fail(requestId, { agentId: value.agentId, runId: value.runId, failureCode: error?.code ?? 'mutation_failed', ...providerErrorField(error) }); + throw withProviderCode(error); + } + if (!exactProviderMutationIdentity(cancelled, { agentId: value.agentId, runId: value.runId })) { + const fields = { + ...submissionFields(value.agentId, value.runId), + ...providerMutationIdentityFields(cancelled), + responseShape: 'cancel-provider-identity-mismatch', + }; + await bestEffortUncertain(this.ledger, requestId, fields, { kind: 'cancel-run', digest }); + throw uncertainSubmissionError( + 'Cursor returned a cancellation acknowledgement whose identity does not exactly match the requested agent and run; the cancellation outcome is not safe to finalize. Reconcile the recorded run or explicitly release this reservation.', + fields, + ); + } + const finalized = submissionFields(value.agentId, value.runId); + try { + await this.ledger.complete(requestId, finalized); + } catch { + await bestEffortUncertain(this.ledger, requestId, finalized, { kind: 'cancel-run', digest }); + throw uncertainSubmissionError('Cursor accepted the cancellation, but durable completion was not confirmed; reconcile the recorded run before retrying.', finalized); + } + return successResult({ + cancelled: redactValue(cancelled, this.secrets(operation)), + agentId: value.agentId, + runId: value.runId, + receipt: mutationReceipt(requestId, digest, { ...finalized, status: 'completed' }), + }); } if (value.action === 'followup') { const input = withMaterializedMcpServers(value, this.env, (values) => addOperationSecrets(operation, values)); @@ -451,22 +882,50 @@ export class CursorCloudService { const requestId = value.requestId; const body = mapFollowupBody(input); const digest = requestDigest('followup-run', { agentId: value.agentId, body: mapFollowupBody(value) }); - const began = await this.ledger.begin({ requestId, kind: 'followup-run', digest, agentId: value.agentId }); + const began = await this.ledger.begin({ requestId, kind: 'followup-run', digest, agentId: value.agentId, providerAgentId: value.agentId }); if (began.duplicate) return successResult({ receipt: { requestId, requestDigest: digest, duplicate: true, status: began.record.status, agentId: began.record.agentId, runId: began.record.runId ?? null } }); let response; try { response = await client.createRun(value.agentId, body); } catch (error) { - if (isAmbiguous(error) || error?.code === 'conflict') { - const fields = submissionFields(value.agentId); + if (isAmbiguous(error)) { + const fields = { ...submissionFields(value.agentId), ...providerErrorField(error) }; await bestEffortUncertain(this.ledger, requestId, fields, { kind: 'followup-run', digest }); throw uncertainSubmissionError('Cursor may have accepted the follow-up but the response was not confirmed; reconcile runs before retrying.', fields); } - await this.ledger.fail(requestId, { agentId: opaqueSubmissionId(value.agentId), failureCode: error?.code ?? 'submission_failed' }); - throw error; + await this.ledger.fail(requestId, { + agentId: opaqueSubmissionId(value.agentId), + failureCode: error?.code ?? 'submission_failed', + ...providerErrorField(error), + }); + throw withProviderCode(error); } const run = response?.run ?? response ?? null; - const finalized = submissionFields(value.agentId, run?.id ?? null); + const runId = opaqueSubmissionId(run?.id ?? null); + if (!runId) { + const fields = { + ...submissionFields(value.agentId, null), + responseShape: 'followup-2xx-without-run-id', + }; + await bestEffortUncertain(this.ledger, requestId, fields, { kind: 'followup-run', digest }); + throw uncertainSubmissionError( + 'Cursor returned success without a provider run ID; the follow-up outcome is not safe to finalize or resubmit. Reconcile the exact run or explicitly release this reservation.', + fields, + ); + } + if (!exactProviderRunIdentity(run, value.agentId, runId)) { + const fields = { + ...submissionFields(value.agentId, runId), + ...providerRunIdentityFields(run), + responseShape: 'followup-provider-run-identity-mismatch', + }; + await bestEffortUncertain(this.ledger, requestId, fields, { kind: 'followup-run', digest }); + throw uncertainSubmissionError( + 'Cursor returned a follow-up run whose identity does not exactly match the requested agent; the follow-up outcome is not safe to finalize. Reconcile the exact run or explicitly release this reservation.', + fields, + ); + } + const finalized = submissionFields(value.agentId, runId); try { await this.ledger.complete(requestId, finalized); } catch { @@ -483,12 +942,36 @@ export class CursorCloudService { const timeoutMs = value.timeoutMs ?? 30_000; const pollMs = value.pollMs ?? 1_000; const deadline = Date.now() + timeoutMs; - let run = await client.getRun(value.agentId, value.runId); - while (!isTerminalRunStatus(run?.status) && Date.now() < deadline) { + let latestRun = null; + let providerTimedOut = false; + const readRun = async () => { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + providerTimedOut = true; + return latestRun; + } + try { + const next = await client.getRun(value.agentId, value.runId, { timeoutMs: remaining }); + if (next && typeof next === 'object') latestRun = next; + return next ?? latestRun; + } catch (error) { + if (error?.code !== 'request_timeout') throw error; + providerTimedOut = true; + const partial = error?.run + ?? error?.latestRun + ?? error?.details?.run + ?? error?.details?.latestRun + ?? error?.details?.partial; + if (partial && typeof partial === 'object') latestRun = partial; + return latestRun; + } + }; + let run = await readRun(); + while (!providerTimedOut && !isTerminalRunStatus(run?.status) && Date.now() < deadline) { await sleep(Math.min(pollMs, Math.max(1, deadline - Date.now()))); - run = await client.getRun(value.agentId, value.runId); + run = await readRun(); } - return successResult({ agentId: value.agentId, runId: value.runId, timedOut: !isTerminalRunStatus(run?.status), run: redactValue(run, this.secrets(operation)) }); + return successResult({ agentId: value.agentId, runId: value.runId, timedOut: providerTimedOut || !isTerminalRunStatus(run?.status), run: redactValue(run ?? latestRun, this.secrets(operation)) }); } const client = await this.getClient(); try { @@ -514,7 +997,15 @@ export class CursorCloudService { const client = await this.getClient(); const listed = await client.artifacts(value.agentId); const items = Array.isArray(listed?.items) ? listed.items : []; - if (value.action === 'list') return successResult({ agentId: value.agentId, artifacts: redactValue({ items: items.slice(0, 200) }, this.secrets(operation)) }); + if (value.action === 'list') { + return successResult({ + agentId: value.agentId, + // Keep the existing hard 200-item bound, but retain provider + // truncation and explicitly report when this local page clipped the + // source list. + artifacts: redactValue(pageResult(listed, 200), this.secrets(operation)), + }); + } const requestedPath = assertSafeArtifactPath(value.path); const found = items.find((entry) => entry?.path === requestedPath); if (!found) throw new CursorApiError('artifact_not_found', 'The requested artifact was not present in Cursor metadata.'); @@ -530,12 +1021,114 @@ export class CursorCloudService { return successResult({ agentId: value.agentId, ...(value.runId ? { runId: value.runId } : {}), usage: redactValue(await client.usage(value.agentId, value.runId), this.secrets(operation)) }); } + async reconcileLifecycle(value, operation) { + await this.requireDurableState(); + const requestId = value.requestId; + const record = await this.ledger.lookup(requestId); + if (!record) throw new CursorApiError('ledger_record_missing', 'No durable lifecycle reservation exists for this request ID.'); + if (!['lifecycle-archive', 'lifecycle-unarchive', 'lifecycle-delete'].includes(record.kind)) { + throw new CursorApiError('reconciliation_not_supported', 'This reconciliation path only supports lifecycle reservations.'); + } + if (record.status === 'completed') return successResult({ requestId, reconciled: false, alreadyFinalized: true, status: record.status, agentId: record.agentId }); + if (record.status === 'failed') { + if (!record.reconciliationReason) throw new CursorApiError('reconciliation_not_required', 'The lifecycle reservation does not require reconciliation.'); + return successResult({ requestId, reconciled: true, alreadyFinalized: true, status: record.status, agentId: record.agentId }); + } + if (record.status === 'pending') throw new CursorApiError('submission_in_progress', 'The lifecycle mutation is still in progress; reconcile it only after uncertainty is recorded.'); + if (record.status !== 'uncertain') throw new CursorApiError('reconciliation_not_required', 'The lifecycle reservation does not require reconciliation.'); + if (value.release === true) { + if (value.confirmation !== `release:${requestId}`) throw new CursorApiError('confirmation_required', `Uncertain reservation release requires confirmation exactly equal to release:${requestId}.`); + const released = await this.ledger.release(requestId, { reason: 'operator_release' }); + return successResult({ requestId, reconciled: true, alreadyFinalized: released.duplicate, status: released.record.status, agentId: released.record.agentId, provider: { state: 'unknown', reservation: 'released', reason: 'operator_release' } }); + } + if (value.agentId !== undefined && value.agentId !== record.agentId) throw new CursorApiError('reconciliation_target_mismatch', 'The provider agent ID does not match the lifecycle reservation.'); + const client = await this.getClient(); + let agent; + try { + agent = await client.getAgent(record.agentId); + } catch (error) { + if (error?.code !== 'not_found' || error?.status !== 404) throw error; + if (record.kind === 'lifecycle-delete') { + const finalized = await this.ledger.complete(requestId, { agentId: record.agentId, providerState: 'not_found' }); + return successResult({ requestId, reconciled: true, alreadyFinalized: finalized.duplicate, status: finalized.record.status, agentId: record.agentId, provider: { state: 'not_found', reservation: 'completed' } }); + } + const released = await this.ledger.reconcile(requestId, { agentId: record.agentId }); + return successResult({ requestId, reconciled: true, alreadyFinalized: released.duplicate, status: released.record.status, agentId: record.agentId, provider: { state: 'not_found', reservation: 'released' } }); + } + if (!exactProviderAgentIdentity(agent, record.agentId)) { + throw uncertainSubmissionError( + 'Cursor returned an agent whose identity does not exactly match the lifecycle target; the reservation remains uncertain.', + { ...submissionFields(record.agentId), ...providerAgentIdentityFields(agent) }, + ); + } + if (record.kind === 'lifecycle-delete') { + throw uncertainSubmissionError('Cursor still returns the agent after the uncertain delete; the reservation remains uncertain.', submissionFields(record.agentId)); + } + const archived = agent?.archived === true || String(agent?.status ?? '').toUpperCase() === 'ARCHIVED'; + const expectedArchived = record.kind === 'lifecycle-archive'; + if (archived !== expectedArchived) { + throw uncertainSubmissionError('Cursor returned the agent, but its lifecycle state does not confirm the requested mutation; the reservation remains uncertain.', { agentId: record.agentId, archived }); + } + const finalized = await this.ledger.complete(requestId, { agentId: record.agentId, providerState: archived ? 'archived' : 'unarchived' }); + return successResult({ requestId, reconciled: true, alreadyFinalized: finalized.duplicate, status: finalized.record.status, agentId: record.agentId, provider: { state: archived ? 'archived' : 'unarchived', reservation: 'completed' }, agent: redactValue(agent, this.secrets(operation)) }); + } + async lifecycle(value, operation) { await this.requireDurableState(); + if (value.action === 'reconcile') return this.reconcileLifecycle(value, operation); const client = await this.getClient(); - if (value.action === 'archive') return successResult({ action: value.action, agentId: value.agentId, result: redactValue(await client.archive(value.agentId), this.secrets(operation)) }); - if (value.action === 'unarchive') return successResult({ action: value.action, agentId: value.agentId, result: redactValue(await client.unarchive(value.agentId), this.secrets(operation)) }); - return successResult({ action: value.action, agentId: value.agentId, irreversible: true, result: redactValue(await client.deleteAgent(value.agentId), this.secrets(operation)) }); + const requestId = value.requestId ?? derivedRequestId(`lifecycle-${value.action}`, { agentId: value.agentId }); + const digest = requestDigest(`lifecycle-${value.action}`, { agentId: value.agentId }); + const kind = `lifecycle-${value.action}`; + const began = await this.ledger.begin({ requestId, kind, digest, agentId: value.agentId, providerAgentId: null }); + if (began.duplicate) { + return successResult({ + action: value.action, + agentId: value.agentId, + ...(value.action === 'delete' ? { irreversible: true } : {}), + receipt: { requestId, requestDigest: digest, duplicate: true, status: began.record.status, agentId: began.record.agentId }, + }); + } + let result; + try { + if (value.action === 'archive') result = await client.archive(value.agentId); + else if (value.action === 'unarchive') result = await client.unarchive(value.agentId); + else result = await client.deleteAgent(value.agentId); + } catch (error) { + if (isAmbiguous(error)) { + const fields = { ...submissionFields(value.agentId), ...providerErrorField(error) }; + await bestEffortUncertain(this.ledger, requestId, fields, { kind, digest }); + throw uncertainSubmissionError(`Cursor may have accepted the ${value.action} mutation but the response was not confirmed; reconcile or explicitly release this reservation before retrying.`, fields); + } + await this.ledger.fail(requestId, { agentId: value.agentId, failureCode: error?.code ?? 'mutation_failed', ...providerErrorField(error) }); + throw withProviderCode(error); + } + if (!exactProviderMutationIdentity(result, { agentId: value.agentId })) { + const fields = { + ...submissionFields(value.agentId), + ...providerMutationIdentityFields(result), + responseShape: `lifecycle-${value.action}-provider-identity-mismatch`, + }; + await bestEffortUncertain(this.ledger, requestId, fields, { kind, digest }); + throw uncertainSubmissionError( + `Cursor returned a ${value.action} acknowledgement whose identity does not exactly match the requested agent; the lifecycle outcome is not safe to finalize. Reconcile the exact agent or explicitly release this reservation.`, + fields, + ); + } + const finalized = submissionFields(value.agentId); + try { + await this.ledger.complete(requestId, finalized); + } catch { + await bestEffortUncertain(this.ledger, requestId, finalized, { kind, digest }); + throw uncertainSubmissionError(`Cursor accepted the ${value.action} mutation, but durable completion was not confirmed; reconcile the recorded agent before retrying.`, finalized); + } + return successResult({ + action: value.action, + agentId: value.agentId, + ...(value.action === 'delete' ? { irreversible: true } : {}), + result: redactValue(result, this.secrets(operation)), + receipt: mutationReceipt(requestId, digest, { ...finalized, status: 'completed' }), + }); } async call(name, rawArguments, operation = createOperationContext(rawArguments)) { @@ -551,15 +1144,184 @@ export class CursorCloudService { } function isAmbiguous(error) { + // Cursor has not accepted a mutation when it returns a conflict or rate + // limit response. Keep those responses retryable through the failed ledger + // state, but never claim that their transport outcome is unknown. + if (error?.code === 'conflict' || error?.status === 409 + || error?.code === 'rate_limited' || error?.status === 429) return false; return error?.ambiguous === true || error?.retryable === true - || ['network_error', 'request_timeout', 'upstream_timeout', 'upstream_failure'].includes(error?.code); + || ['network_error', 'request_timeout', 'upstream_timeout', 'upstream_failure', 'invalid_json', 'invalid_content_type', 'response_too_large'].includes(error?.code); +} + +function providerErrorField(error) { + const providerCode = error?.providerCode ?? error?.details?.providerCode; + const runId = error?.providerRunId ?? error?.details?.runId ?? error?.details?.run_id; + return { + ...(typeof providerCode === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(providerCode) ? { providerCode } : {}), + ...(typeof runId === 'string' && /^run-[A-Za-z0-9][A-Za-z0-9_-]{2,127}$/.test(runId) ? { runId } : {}), + }; +} + +function withProviderCode(error) { + const fields = providerErrorField(error); + if (!fields.providerCode || error?.details?.providerCode === fields.providerCode) return error; + return new CursorApiError(error?.code ?? 'internal_error', error?.message ?? 'Cursor API request failed.', { + status: error?.status, + details: { ...(error?.details && typeof error.details === 'object' ? error.details : {}), ...fields }, + retryable: error?.retryable === true, + ambiguous: error?.ambiguous === true, + providerCode: fields.providerCode, + rateWindow: error?.rateWindow, + }); +} + +function isProviderNotFound(error) { + return error?.code === 'not_found' && error?.status === 404; +} + +async function confirmProviderAgent(client, agentId) { + let runsWereAvailable = false; + for (let attempt = 0; attempt < RECONCILIATION_404_ATTEMPTS; attempt += 1) { + try { + const agent = await client.getAgent(agentId); + if (!exactProviderAgentIdentity(agent, agentId)) return { state: 'mismatch', agent }; + return { state: 'found', agent }; + } catch (error) { + if (!isProviderNotFound(error)) throw error; + } + + try { + const response = await client.listRuns(agentId, {}); + const runs = Array.isArray(response?.items) ? response.items : []; + // A provider endpoint scoped to agentId must not be allowed to turn an + // explicitly mismatched run into evidence that the requested agent is + // absent. Treat malformed/mismatched list entries as inconsistent so a + // 404 can never release the reservation on cross-agent evidence. + if (runs.some((run) => !exactProviderRunIdentity(run, agentId, opaqueSubmissionId(run?.id ?? null)))) { + return { state: 'mismatch', runs }; + } + runsWereAvailable = true; + } catch (error) { + if (!isProviderNotFound(error)) throw error; + } + + if (attempt + 1 < RECONCILIATION_404_ATTEMPTS) { + await sleep(RECONCILIATION_404_BACKOFF_MS * (2 ** attempt)); + } + } + return runsWereAvailable ? { state: 'inconsistent' } : { state: 'absent' }; +} + +async function findProviderAssignedAgent(client, record) { + const hints = record?.reconciliationHints; + if (!hints || !record?.reconciliationFingerprint) return { state: 'unavailable' }; + const matches = []; + let cursor; + // Provider-assigned IDs cannot be recovered by guessing. Search only a + // bounded number of provider pages and report exact fingerprint matches as + // diagnostics; reservation-time provenance is required before finalization. + for (let page = 0; page < 5; page += 1) { + const response = await client.listAgents({ limit: 100, cursor, includeArchived: true }); + const items = Array.isArray(response?.items) ? response.items : []; + for (const item of items) { + if (providerAgentMatchesHints(item, hints)) matches.push(item); + } + const next = typeof response?.nextCursor === 'string' && response.nextCursor.length > 0 + ? response.nextCursor : null; + if (!next || next === cursor || items.length === 0) break; + cursor = next; + } + if (matches.length === 1) return { state: 'found', agent: matches[0] }; + if (matches.length > 1) return { state: 'ambiguous' }; + return { state: 'absent' }; } function opaqueSubmissionId(value) { return typeof value === 'string' && value.length > 0 ? value : null; } +function isProviderRecord(value) { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); +} + +// These identity checks are deliberately stricter than the provider endpoint +// paths. A path parameter is caller intent; only the returned object's exact +// opaque IDs can attest that reconciliation observed the requested object. +function exactProviderAgentIdentity(agent, expectedAgentId) { + return isProviderRecord(agent) + && typeof expectedAgentId === 'string' + && typeof agent.id === 'string' + && agent.id === expectedAgentId; +} + +function providerRunAgentId(run) { + if (!isProviderRecord(run)) return null; + const values = []; + for (const key of ['agentId', 'agent_id']) { + if (!Object.hasOwn(run, key)) continue; + if (typeof run[key] !== 'string' || run[key].length === 0) return null; + values.push(run[key]); + } + if (values.length === 0 || values.some((value) => value !== values[0])) return null; + return values[0]; +} + +function exactProviderRunIdentity(run, expectedAgentId, expectedRunId) { + return isProviderRecord(run) + && typeof expectedAgentId === 'string' + && typeof expectedRunId === 'string' + && run.id === expectedRunId + && providerRunAgentId(run) === expectedAgentId; +} + +function providerAgentIdentityFields(agent) { + return { + providerReturnedAgentId: opaqueSubmissionId(agent?.id ?? null), + }; +} + +function providerRunIdentityFields(run) { + return { + providerReturnedRunId: opaqueSubmissionId(run?.id ?? null), + providerReturnedRunAgentId: providerRunAgentId(run), + }; +} + +function providerMutationIdentityFields(response) { + return { + ...providerAgentIdentityFields(response?.agent ?? response), + ...providerRunIdentityFields(response?.run ?? response), + }; +} + +function exactProviderMutationIdentity(response, { agentId, runId }) { + if (response === undefined || response === null) return false; + if (!isProviderRecord(response)) return false; + + if (Object.hasOwn(response, 'agent')) { + if (!exactProviderAgentIdentity(response.agent, agentId)) return false; + } + if (Object.hasOwn(response, 'run')) { + if (!exactProviderRunIdentity(response.run, agentId, runId ?? opaqueSubmissionId(response.run?.id ?? null))) return false; + } + + // Mutation endpoints may return a small acknowledgement rather than the + // full provider object (including a 204 mapped to {}). Validate every + // identity field they do return; an empty object remains an opaque ack. + const returnedId = Object.hasOwn(response, 'id') ? response.id + : Object.hasOwn(response, 'runId') ? response.runId + : undefined; + if (returnedId !== undefined) { + const expectedId = runId ?? agentId; + if (returnedId !== expectedId) return false; + } + for (const key of ['agentId', 'agent_id']) { + if (Object.hasOwn(response, key) && response[key] !== agentId) return false; + } + return true; +} + function submissionFields(agentId, runId = null) { return { agentId: opaqueSubmissionId(agentId), runId: opaqueSubmissionId(runId) }; } diff --git a/plugins/cursor-cloud-control/mcp/validation.mjs b/plugins/cursor-cloud-control/mcp/validation.mjs index 3ca616e..4304045 100644 --- a/plugins/cursor-cloud-control/mcp/validation.mjs +++ b/plugins/cursor-cloud-control/mcp/validation.mjs @@ -648,9 +648,10 @@ export const TOOL_SCHEMAS = Object.freeze({ agents: { type: 'object', properties: { - action: { type: 'string', enum: ['list', 'get', 'create'] }, + action: { type: 'string', enum: ['list', 'get', 'create', 'reconcile'] }, requestId: { type: 'string', pattern: REQUEST_ID_PATTERN.source }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source }, + release: { type: 'boolean' }, confirmation: { type: 'string', minLength: 1, maxLength: 256 }, prompt: PROMPT_SCHEMA, model: MODEL_SCHEMA, name: { type: 'string', maxLength: 100 }, env: ENV_SCHEMA, repos: REPOS_SCHEMA, workOnCurrentBranch: { type: 'boolean' }, autoCreatePR: { type: 'boolean' }, skipReviewerRequest: { type: 'boolean' }, @@ -663,13 +664,14 @@ export const TOOL_SCHEMAS = Object.freeze({ { properties: { action: { const: 'list' }, limit: { type: 'integer', minimum: 1, maximum: 100 }, cursor: { type: 'string', minLength: 1, maxLength: 512 }, prUrl: { type: 'string', format: 'uri' }, includeArchived: { type: 'boolean' } }, required: ['action'], additionalProperties: false }, { properties: { action: { const: 'get' }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source } }, required: ['action', 'agentId'], additionalProperties: false }, { properties: { action: { const: 'create' }, requestId: { type: 'string', pattern: REQUEST_ID_PATTERN.source }, prompt: PROMPT_SCHEMA, model: MODEL_SCHEMA, name: { type: 'string', maxLength: 100 }, env: ENV_SCHEMA, repos: REPOS_SCHEMA, workOnCurrentBranch: { type: 'boolean' }, autoCreatePR: { type: 'boolean' }, skipReviewerRequest: { type: 'boolean' }, envVars: ENV_VARS_SCHEMA, mcpServers: MCP_SERVERS_SCHEMA, customSubagents: SUBAGENTS_SCHEMA, mode: { type: 'string', enum: ['agent', 'plan'] }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source } }, required: ['action', 'requestId', 'prompt'], additionalProperties: false }, + { properties: { action: { const: 'reconcile' }, requestId: { type: 'string', pattern: REQUEST_ID_PATTERN.source }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source }, release: { type: 'boolean' }, confirmation: { type: 'string', minLength: 1, maxLength: 256 } }, required: ['action', 'requestId'], additionalProperties: false }, ], additionalProperties: false, }, runs: { type: 'object', properties: { - action: { type: 'string', enum: ['list', 'get', 'followup', 'wait', 'stream', 'cancel'] }, + action: { type: 'string', enum: ['list', 'get', 'followup', 'wait', 'stream', 'cancel', 'reconcile'] }, requestId: { type: 'string', pattern: REQUEST_ID_PATTERN.source }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source }, runId: { type: 'string', pattern: RUN_ID_PATTERN.source }, prompt: PROMPT_SCHEMA, mcpServers: MCP_SERVERS_SCHEMA, mode: { type: 'string', enum: ['agent', 'plan'] }, @@ -684,7 +686,8 @@ export const TOOL_SCHEMAS = Object.freeze({ { properties: { action: { const: 'followup' }, requestId: { type: 'string', pattern: REQUEST_ID_PATTERN.source }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source }, prompt: PROMPT_SCHEMA, mcpServers: MCP_SERVERS_SCHEMA, mode: { type: 'string', enum: ['agent', 'plan'] } }, required: ['action', 'requestId', 'agentId', 'prompt'], additionalProperties: false }, { properties: { action: { const: 'wait' }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source }, runId: { type: 'string', pattern: RUN_ID_PATTERN.source }, timeoutMs: { type: 'integer', minimum: 250, maximum: 60000 }, pollMs: { type: 'integer', minimum: 250, maximum: 10000 } }, required: ['action', 'agentId', 'runId'], additionalProperties: false }, { properties: { action: { const: 'stream' }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source }, runId: { type: 'string', pattern: RUN_ID_PATTERN.source }, lastEventId: { type: 'string', minLength: 1, maxLength: 512 }, timeoutMs: { type: 'integer', minimum: 250, maximum: 60000 }, maxEvents: { type: 'integer', minimum: 1, maximum: 500 }, maxBytes: { type: 'integer', minimum: 1024, maximum: 2_000_000 } }, required: ['action', 'agentId', 'runId'], additionalProperties: false }, - { properties: { action: { const: 'cancel' }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source }, runId: { type: 'string', pattern: RUN_ID_PATTERN.source } }, required: ['action', 'agentId', 'runId'], additionalProperties: false }, + { properties: { action: { const: 'cancel' }, requestId: { type: 'string', pattern: REQUEST_ID_PATTERN.source }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source }, runId: { type: 'string', pattern: RUN_ID_PATTERN.source } }, required: ['action', 'agentId', 'runId'], additionalProperties: false }, + { properties: { action: { const: 'reconcile' }, requestId: { type: 'string', pattern: REQUEST_ID_PATTERN.source }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source }, runId: { type: 'string', pattern: RUN_ID_PATTERN.source }, release: { type: 'boolean' }, confirmation: { type: 'string', minLength: 1, maxLength: 256 } }, required: ['action', 'requestId'], additionalProperties: false }, ], additionalProperties: false, }, @@ -710,13 +713,15 @@ export const TOOL_SCHEMAS = Object.freeze({ lifecycle: { type: 'object', properties: { - action: { type: 'string', enum: ['archive', 'unarchive', 'delete'] }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source }, + action: { type: 'string', enum: ['archive', 'unarchive', 'delete', 'reconcile'] }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source }, + requestId: { type: 'string', pattern: REQUEST_ID_PATTERN.source }, release: { type: 'boolean' }, confirmation: { type: 'string', minLength: 1, maxLength: 256 }, }, oneOf: [ - { properties: { action: { const: 'archive' }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source } }, required: ['action', 'agentId'], additionalProperties: false }, - { properties: { action: { const: 'unarchive' }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source } }, required: ['action', 'agentId'], additionalProperties: false }, - { properties: { action: { const: 'delete' }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source }, confirmation: { type: 'string', minLength: 1, maxLength: 256 } }, required: ['action', 'agentId', 'confirmation'], additionalProperties: false }, + { properties: { action: { const: 'archive' }, requestId: { type: 'string', pattern: REQUEST_ID_PATTERN.source }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source } }, required: ['action', 'agentId'], additionalProperties: false }, + { properties: { action: { const: 'unarchive' }, requestId: { type: 'string', pattern: REQUEST_ID_PATTERN.source }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source } }, required: ['action', 'agentId'], additionalProperties: false }, + { properties: { action: { const: 'delete' }, requestId: { type: 'string', pattern: REQUEST_ID_PATTERN.source }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source }, confirmation: { type: 'string', minLength: 1, maxLength: 256 } }, required: ['action', 'agentId', 'confirmation'], additionalProperties: false }, + { properties: { action: { const: 'reconcile' }, requestId: { type: 'string', pattern: REQUEST_ID_PATTERN.source }, agentId: { type: 'string', pattern: AGENT_ID_PATTERN.source }, release: { type: 'boolean' }, confirmation: { type: 'string', minLength: 1, maxLength: 256 } }, required: ['action', 'requestId'], additionalProperties: false }, ], additionalProperties: false, }, @@ -742,22 +747,37 @@ export function validateToolInput(toolName, raw) { } if (toolName === 'agents') { required(value, ['action']); - validateAction(value, ['list', 'get', 'create']); + validateAction(value, ['list', 'get', 'create', 'reconcile']); if (value.action === 'list') { pageFields(value, { includeArchived: true }); return value; } if (value.action === 'get') { unknown(value, ['action', 'agentId'], 'arguments'); id(value.agentId, 'arguments.agentId'); return value; } + if (value.action === 'reconcile') { + unknown(value, ['action', 'requestId', 'agentId', 'release', 'confirmation'], 'arguments'); + required(value, ['requestId']); + requestId(value.requestId); + if (value.agentId !== undefined) id(value.agentId, 'arguments.agentId'); + boolean(value.release, 'arguments.release', true); + string(value.confirmation, 'arguments.confirmation', { min: 1, max: 256, optional: true }); + if (value.release === true && value.confirmation === undefined) fail('arguments.confirmation is required when release is true.'); + return value; + } return validateCreate(value); } if (toolName === 'runs') { required(value, ['action']); - validateAction(value, ['list', 'get', 'followup', 'wait', 'stream', 'cancel']); + validateAction(value, ['list', 'get', 'followup', 'wait', 'stream', 'cancel', 'reconcile']); if (value.action === 'list') { unknown(value, ['action', 'agentId', 'limit', 'cursor'], 'arguments'); id(value.agentId, 'arguments.agentId'); pageFields({ limit: value.limit, cursor: value.cursor }); return value; } - if (value.action === 'get' || value.action === 'cancel') { + if (value.action === 'get') { unknown(value, ['action', 'agentId', 'runId'], 'arguments'); id(value.agentId, 'arguments.agentId'); id(value.runId, 'arguments.runId', 'run'); return value; } + if (value.action === 'cancel') { + unknown(value, ['action', 'requestId', 'agentId', 'runId'], 'arguments'); + id(value.agentId, 'arguments.agentId'); id(value.runId, 'arguments.runId', 'run'); requestId(value.requestId); + return value; + } if (value.action === 'followup') return validateFollowup(value); if (value.action === 'wait') { unknown(value, ['action', 'agentId', 'runId', 'timeoutMs', 'pollMs'], 'arguments'); @@ -765,6 +785,16 @@ export function validateToolInput(toolName, raw) { integer(value.timeoutMs, 'arguments.timeoutMs', { min: 250, max: 60000, optional: true }); integer(value.pollMs, 'arguments.pollMs', { min: 250, max: 10000, optional: true }); return value; } + if (value.action === 'reconcile') { + unknown(value, ['action', 'requestId', 'agentId', 'runId', 'release', 'confirmation'], 'arguments'); + required(value, ['requestId']); + requestId(value.requestId); if (value.agentId !== undefined) id(value.agentId, 'arguments.agentId', 'agent'); + if (value.runId !== undefined) id(value.runId, 'arguments.runId', 'run'); + boolean(value.release, 'arguments.release', true); + string(value.confirmation, 'arguments.confirmation', { min: 1, max: 256, optional: true }); + if (value.release === true && value.confirmation === undefined) fail('arguments.confirmation is required when release is true.'); + return value; + } unknown(value, ['action', 'agentId', 'runId', 'lastEventId', 'timeoutMs', 'maxEvents', 'maxBytes'], 'arguments'); id(value.agentId, 'arguments.agentId'); id(value.runId, 'arguments.runId', 'run'); string(value.lastEventId, 'arguments.lastEventId', { min: 1, max: 512, optional: true }); @@ -791,16 +821,31 @@ export function validateToolInput(toolName, raw) { return value; } if (toolName === 'lifecycle') { - required(value, ['action', 'agentId']); - validateAction(value, ['archive', 'unarchive', 'delete']); + required(value, ['action']); + validateAction(value, ['archive', 'unarchive', 'delete', 'reconcile']); + if (value.action === 'reconcile') { + unknown(value, ['action', 'requestId', 'agentId', 'release', 'confirmation'], 'arguments'); + required(value, ['requestId']); + requestId(value.requestId); + if (value.agentId !== undefined) id(value.agentId, 'arguments.agentId'); + boolean(value.release, 'arguments.release', true); + string(value.confirmation, 'arguments.confirmation', { min: 1, max: 256, optional: true }); + if (value.release === true && value.confirmation === undefined) fail('arguments.confirmation is required when release is true.'); + return value; + } + required(value, ['agentId']); id(value.agentId, 'arguments.agentId'); if (value.action === 'delete') { - unknown(value, ['action', 'agentId', 'confirmation'], 'arguments'); + unknown(value, ['action', 'requestId', 'agentId', 'confirmation'], 'arguments'); + requestId(value.requestId); string(value.confirmation, 'arguments.confirmation', { min: 1, max: 256 }); if (value.confirmation !== `delete:${value.agentId}`) { throw new InputError('confirmation_required', `Deletion requires confirmation exactly equal to delete:${value.agentId}.`); } - } else unknown(value, ['action', 'agentId'], 'arguments'); + } else { + unknown(value, ['action', 'requestId', 'agentId'], 'arguments'); + requestId(value.requestId); + } return value; } throw new InputError('unknown_tool', `Unknown tool ${toolName}.`); diff --git a/plugins/cursor-cloud-control/package.json b/plugins/cursor-cloud-control/package.json index 99d76de..8a518f5 100644 --- a/plugins/cursor-cloud-control/package.json +++ b/plugins/cursor-cloud-control/package.json @@ -1,8 +1,8 @@ { "name": "cursor-cloud-control", - "version": "0.3.0", + "version": "0.4.0", "private": false, - "description": "Cursor Cloud Control plus a distinct typed, fail-closed Cursor Local CLI status surface.", + "description": "Cursor Cloud Control plus an explicitly activated host-trusted local Cursor CLI control surface with separate state and receipts.", "license": "MIT", "engines": { "node": ">=24.0.0" @@ -11,10 +11,10 @@ "files": [ ".codex-plugin", ".mcp.json", + "LICENSE", "README.md", "mcp", "skills", - "test", "package.json" ], "scripts": { diff --git a/plugins/cursor-cloud-control/skills/control-cursor-cloud-agents/SKILL.md b/plugins/cursor-cloud-control/skills/control-cursor-cloud-agents/SKILL.md index 991eb0a..2a9f657 100644 --- a/plugins/cursor-cloud-control/skills/control-cursor-cloud-agents/SKILL.md +++ b/plugins/cursor-cloud-control/skills/control-cursor-cloud-agents/SKILL.md @@ -9,6 +9,8 @@ Use the `cursor-cloud-control` MCP server for Cursor Cloud Agents API v1. Do not ask the user for an API key in chat and do not place a key in a tool argument. The MCP process reads `CURSOR_API_KEY` or an owner-only `CURSOR_API_KEY_FILE`. +This skill ships with Cursor Cloud Control `0.4.0`; its cloud server identity +is versioned independently from the local `cursor-local-control` wire identity. If neither is set, it discovers `$XDG_CONFIG_HOME/cursor-cloud-control/api-key` or `$HOME/.config/cursor-cloud-control/api-key` when that file is owner-only. @@ -55,12 +57,35 @@ repository starting refs, model resolution, and remote workspace head/branch as unverified unless Cursor returns a documented attestation. The legacy `effectiveConfiguration` field is marked `provenance: "caller-derived"` and `deprecated: true`; it is not provider evidence. Never infer that a timeout -means no agent was created. A receipt with `uncertain_submission` requires -`agents.get` and `runs.list` reconciliation before any new request ID is used. +means no agent was created. When `agentId` is omitted, the plugin does not +send its local reservation ID to Cursor. `agents` `action=reconcile` performs +a bounded listing as a diagnostic only. A hash-only fingerprint, even when it +matches one provider agent exactly, has no reservation-time provenance and may +identify a pre-existing agent, so the reservation remains uncertain. Never +guess an ID, finalize a listing match, or resubmit. An explicit +`release:` confirmation is available when the caller has +accepted that provider state cannot be proven. When a caller-supplied +`agentId` is present, reconciliation repeats `agents.get` and `runs.list` +with a bounded backoff and releases the reservation only after both paths +consistently return provider HTTP 404. Never bind an arbitrary ID to a +reservation that has no stored provider target. Different request IDs remain +independent unless they reuse the same explicit provider agent ID. + +HTTP 409 conflicts and HTTP 429 rate limits are definitive provider failures, +not transport uncertainty; retry only after the returned provider error has +been handled. Use `runs` with `action=followup` only for a known agent ID. Follow-ups are non-idempotent and are never automatically retried. A stable request ID is -required so transport failures remain reconcilable. +required so transport failures remain reconcilable; use `runs` +`action=reconcile` with the exact observed provider run ID, or explicitly +release with `release:`. A successful follow-up response without an +opaque provider run ID remains uncertain. Cancellation reconciliation completes +only for the exact run when its provider status is terminal `CANCELLED` or +`CANCELED`; otherwise it remains uncertain. `runs.wait` returns a bounded +`timedOut` receipt for provider `request_timeout` while retaining the latest +confirmed run. Cancellation also has a durable receipt; reconcile or release +an uncertain cancellation before issuing another request. Remote MCP servers may use the typed `authEnv` and `headerEnv` wrappers. These fields contain only environment variable names, not credential values. The @@ -74,8 +99,9 @@ Use `runs` `action=stream` with bounded `timeoutMs`, `maxEvents`, and `maxBytes`. Keep the returned `lastEventId`; pass it as `lastEventId` to resume after a disconnect. Unknown events are preserved as bounded event names/data. For a 410 stream expiry or bounded timeout, use `runs` `action=get` or -`action=wait`. Use `runs` `action=cancel` with the exact agent and run IDs; -Cursor cancellation is terminal. +`action=wait`. Use `runs` `action=cancel` with the exact agent and run IDs. The +cancellation request is durably keyed and returns a receipt; Cursor +cancellation is terminal. ## Artifacts and lifecycle @@ -86,7 +112,10 @@ and never executes or renders the file. Use `lifecycle` `archive` for reversible removal and `unarchive` to resume an archived agent. Permanent `delete` requires confirmation exactly equal to -`delete:` and must be treated as irreversible. +`delete:` and must be treated as irreversible. Lifecycle calls are +durably keyed; use `lifecycle` `action=reconcile` for uncertain state or the +explicit `release:` confirmation when provider state cannot be +proven. No reconciliation or release path resubmits the mutation. Refer to the plugin README and the current Cursor endpoint reference for API details and beta-compatibility caveats. Bearer authentication is the plugin diff --git a/plugins/cursor-cloud-control/skills/control-cursor-local-cli/SKILL.md b/plugins/cursor-cloud-control/skills/control-cursor-local-cli/SKILL.md index fbe3dab..8e54a6f 100644 --- a/plugins/cursor-cloud-control/skills/control-cursor-local-cli/SKILL.md +++ b/plugins/cursor-cloud-control/skills/control-cursor-local-cli/SKILL.md @@ -10,11 +10,15 @@ Agent CLI. Do not use the `cursor-cloud-control` server's `agents` or `runs` tools for local work, and do not pass Cloud `agentId` or `runId` values to the local server. Local IDs begin with `lrun-`; local state and receipts live in a different owner-only ledger. +The local server wire identity is `cursor-local-control` `0.2.0`, shipped with +Cursor Cloud Control `0.4.0`; keep the cloud and local ledgers separate. -This release exposes only the local `status` tool. The typed `run`/`runs` -foundation remains packaged for review, but is not in the MCP catalog; -direct calls fail with `foundation_not_exposed` before they can spawn or adopt -Cursor. Do not treat a passing sandbox preflight as host acceptance. +The public/default catalog exposes only the local `status` tool. An +administrator may explicitly set `CURSOR_LOCAL_CLI_ENABLE_HOST_TRUSTED_RUNS=1` +to expose the typed `run`/`runs` tools. Every `run` call must then include +`execution_profile: "host_trusted"`. This profile invokes the selected +`cursor-agent` directly as the MCP process user; it does not invoke Bubblewrap +and is not a confidentiality, network, or filesystem sandbox. ## Before local diagnostics @@ -26,45 +30,67 @@ Cursor. Do not treat a passing sandbox preflight as host acceptance. response is a compact state/method projection and never includes account identity or credential values. 3. Call `status` with `action: "permissions"` for the administrator-owned - CLI config. Runs fail closed unless the config is owner-only, schema v1, - non-unrestricted, and denies all MCP tools. A future read-only execution - profile additionally requires explicit `Write(**)` and `Shell(*)` deny - rules; current runs remain deferred regardless of configuration. + CLI config. The host-trusted profile inherits Cursor's normal CLI approval + configuration; the control plane does not claim that configuration is a + sandbox or silently widen it. Host-trusted `read_only` additionally + requires explicit `Write(**)`, `Shell(*)`, and `Mcp(*:*)` deny rules. 4. Use only an absolute workspace that the administrator configured in `CURSOR_LOCAL_CLI_WORKSPACE_ROOTS`. Never broaden that allowlist in a tool argument. -Status also reports the administrator-pinned `CURSOR_LOCAL_CLI_SHA256` and the -native `bwrap` preflight configured by `CURSOR_LOCAL_CLI_SANDBOX_BIN` plus -`CURSOR_LOCAL_CLI_SANDBOX_SHA256`. Digest drift or an unavailable preflight is -not recoverable by a tool caller. +Status reports the optional administrator-pinned `CURSOR_LOCAL_CLI_SHA256` and +the provider-free native `bwrap` preflight configured by +`CURSOR_LOCAL_CLI_SANDBOX_BIN` plus `CURSOR_LOCAL_CLI_SANDBOX_SHA256`. The +host-trusted run profile does not use that preflight; an unavailable or +unattested bwrap binary never becomes a reason to claim a stronger boundary. The local process receives only a sanitized environment. A local API key may be supplied through the administrator-only `CURSOR_LOCAL_CLI_API_KEY` environment value; it is never accepted as a tool argument and is not taken from the Cloud credential file automatically. -## Deferred modes - -The foundation `run` contract requires an explicit `mode`, but it is not -advertised by this release: - -- `read_only` is specified to use Cursor print/Ask mode and never pass - `--force` or `--yolo`; it is not enabled in this release. -- `implement` is specified to require explicit `--force` and an isolated - worktree under the configured local CLI home; it is not enabled in this - release. - -The deferred contract retains bounded `timeoutMs`, `waitMs`, `maxEvents`, and -`maxBytes` fields. `stream-json` events are parsed as bounded NDJSON in the -foundation code, but no provider process is started until a future host -acceptance gate proves the complete boundary. - -Acceptance is still blocked on a real Cursor process test: the prototype -Bubblewrap root bind is not by itself a confidentiality or network boundary, -and future lifecycle tests must cover resource limits, TERM-to-KILL -escalation, process-group ownership, and restart recovery. A digest pin alone -is not an execution attestation. +## Host-trusted execution + +Host-trusted execution is an administrator opt-in, not the public default: + +```text +CURSOR_LOCAL_CLI_ENABLE_HOST_TRUSTED_RUNS=1 +``` + +After activation, `run` requires an absolute allowlisted workspace, a prompt, +an explicit task `mode`, and `execution_profile: "host_trusted"`: + +```json +{ + "workspace": "/absolute/allowlisted/checkout", + "prompt": "Review the current implementation and report findings.", + "requestId": "local-review-20260819-0001", + "mode": "read_only", + "execution_profile": "host_trusted" +} +``` + +- `read_only` uses Cursor print/Ask mode and never passes `--force` or + `--yolo`. +- `implement` is write-capable, requires the explicit `implement` mode, uses + Cursor's `--force` flag for noninteractive execution, and uses an isolated + Cursor worktree by default. +- Both modes use the direct `cursor-agent` executable with Cursor's provider + sandbox disabled. The receipt says `outerSandbox: "none"`, + `providerSandbox: "disabled"`, and `authority: "mcp_process_user"`. +- The workspace allowlist and bounded timeout/event/log limits remain control + plane limits. They do not prevent a host-trusted Cursor process from using + any filesystem or network authority available to its OS user. + +The contract retains bounded `timeoutMs`, `waitMs`, `maxEvents`, and `maxBytes` +fields. `stream-json` events are parsed as bounded NDJSON. Cancellation and +timeout send `SIGTERM` to the owned process group, then escalate to `SIGKILL` +after the grace interval when the group remains alive. + +The retained Bubblewrap foundation remains separate and unwired. It must not +be described as the boundary for host-trusted runs. A real Cursor process +acceptance check is still required before treating a particular host setup as +operational, including Cursor project-state/trust setup and process cleanup. ## Authentication and installation @@ -74,20 +100,28 @@ the installer replaces both `agent` and `cursor-agent` links. Keep a dedicated Cursor executable path and configure `CURSOR_LOCAL_CLI_BIN` explicitly. The documented browser flow is `cursor-agent login` and `cursor-agent status`; -automation may use the local-only API-key environment value. Never use +host-trusted execution can use the administrator-selected local Cursor home, +or the MCP process `HOME` when no separate home is configured. Automation may +use the local-only API-key environment value. Never use `--api-key`, because it exposes the key in process arguments. Do not invoke `agent update` through this MCP surface; binary upgrades require owner review, digest capture, and a fresh local status check. ## Receipts and cancellation -The future lifecycle is `accepted -> started -> working -> terminal`, with +The lifecycle is `accepted -> started -> working -> terminal`, with terminal states `succeeded`, `failed`, `cancelled`, `timed_out`, `transport_lost`, `environment_blocked`, `binary_drift`, or -`workspace_changed`. Cancellation will signal only a process group owned by -the current MCP server; an orphaned process is not guessed at or signalled -after a server restart. +`workspace_changed`. Cancellation signals only a process group whose exact PID +start token still matches the durable launch receipt. After an MCP server +restart, reconciliation may terminate a surviving child only when that same +durable token proves it is the originally launched process; a missing or +mismatched token is treated as transport loss and is never signalled. Local receipts contain bounded operational metadata, digests, worktree -identity, and compact logs. They do not contain prompts, raw transcripts, -credentials, Cloud IDs, or entries in the Cloud submission ledger. +identity, explicit host-trusted boundary/authority fields, and compact logs. +They do not contain prompts, raw transcripts, credentials, Cloud IDs, or +entries in the Cloud submission ledger. Host-trusted receipts deliberately +report `workspaceChanged: null` because this direct process has no outer +filesystem observer; a successful exit is not a proof that the workspace was +unchanged. diff --git a/plugins/cursor-cloud-control/test/client.test.mjs b/plugins/cursor-cloud-control/test/client.test.mjs index 0b46616..4c2195c 100644 --- a/plugins/cursor-cloud-control/test/client.test.mjs +++ b/plugins/cursor-cloud-control/test/client.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -33,6 +33,33 @@ test('discovers the default owner-only key file without returning its contents', await assert.rejects(loadApiKey(env), (error) => error.code === 'credential_file_permissions'); }); +test('credential reads reject a pathname replacement after the descriptor-bound read', async (context) => { + const home = await mkdtemp(path.join(os.tmpdir(), 'cursor-cloud-credential-swap-')); + context.after(() => rm(home, { recursive: true, force: true })); + const env = { HOME: home }; + const file = defaultApiKeyFile(env); + const displaced = `${file}.displaced`; + await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); + await writeFile(file, 'unit-secret-value\n', { mode: 0o600 }); + const probe = await open(file, 'r'); + const prototype = Object.getPrototypeOf(probe); + await probe.close(); + const originalReadFile = prototype.readFile; + let swapped = false; + prototype.readFile = async function (...arguments_) { + const contents = await originalReadFile.apply(this, arguments_); + if (!swapped) { + swapped = true; + await rename(file, displaced); + await writeFile(file, 'attacker-value\n', { mode: 0o600 }); + } + return contents; + }; + context.after(() => { prototype.readFile = originalReadFile; }); + await assert.rejects(loadApiKey(env), (error) => error.code === 'credential_file_permissions'); + assert.equal(swapped, true); +}); + test('rejects non-TLS production origin overrides', () => { assert.throws(() => new CursorApiClient({ apiKey: 'unit-secret-value', origin: 'http://public.example' }), (error) => error.code === 'invalid_origin'); assert.doesNotThrow(() => new CursorApiClient({ apiKey: 'unit-secret-value', origin: 'http://127.0.0.1:12345' })); diff --git a/plugins/cursor-cloud-control/test/ledger.test.mjs b/plugins/cursor-cloud-control/test/ledger.test.mjs index b1e8500..1f2f9b8 100644 --- a/plugins/cursor-cloud-control/test/ledger.test.mjs +++ b/plugins/cursor-cloud-control/test/ledger.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; -import { chmod, link, lstat, mkdir, mkdtemp, open, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { chmod, link, lstat, mkdir, mkdtemp, open, readFile, rename, rm, symlink, utimes, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -47,6 +48,23 @@ function deferred() { return { promise, resolve }; } +function runLedgerChild(stateDir, requestId) { + const source = `import { SubmissionLedger, requestDigest } from ${JSON.stringify(new URL('../mcp/ledger.mjs', import.meta.url).href)}; +const ledger = new SubmissionLedger({ stateDir: ${JSON.stringify(stateDir)}, lockTimeoutMs: 2000, lockRetryMs: 1, lockStaleMs: 25 }); +const digest = requestDigest('child', ${JSON.stringify(requestId)}); +try { await ledger.begin({ requestId: ${JSON.stringify(requestId)}, kind: 'child', digest }); process.stdout.write('ok'); } +catch (error) { process.stdout.write(JSON.stringify({ code: error.code, message: error.message })); process.exitCode = 1; }`; + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--no-warnings', '--input-type=module', '-e', source], { env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('close', (code) => resolve({ code, stdout, stderr })); + }); +} + test('configured state is created owner-only and readiness does not create a ledger record', async (context) => { const root = await stateFixture(context); const stateDir = path.join(root, 'state'); @@ -110,6 +128,23 @@ test('definitive failures leave a retryable reservation instead of a pending rec assert.ok(retry.record.owner?.token); }); +test('provider-ID reservations still block a failed-request retry when another request is uncertain', async (context) => { + const root = await stateFixture(context, 'cursor-ledger-provider-id-conflict-'); + const stateDir = path.join(root, 'state'); + const ledger = new SubmissionLedger({ stateDir }); + const providerAgentId = 'bc-00000000-0000-0000-0000-000000000001'; + + await ledger.begin({ requestId: 'failed-request-1', kind: 'agents.create', digest, agentId: providerAgentId, providerAgentId }); + await ledger.fail('failed-request-1', { failureCode: 'bad_request' }); + await ledger.begin({ requestId: 'uncertain-request-2', kind: 'agents.create', digest: otherDigest, agentId: providerAgentId, providerAgentId }); + await ledger.uncertain('uncertain-request-2', { agentId: providerAgentId }); + + await assertLedgerError( + () => ledger.begin({ requestId: 'failed-request-1', kind: 'agents.create', digest, agentId: providerAgentId, providerAgentId }), + 'uncertain_submission', + ); +}); + test('finalization fails closed when the durable reservation disappears', async (context) => { const root = await stateFixture(context, 'cursor-ledger-missing-final-record-'); const stateDir = path.join(root, 'state'); @@ -155,6 +190,57 @@ test('stale pending reservations become uncertain and persist reconciliation met ); }); +test('record cap evicts terminal history only and preserves all active reservations across restart', async (context) => { + const root = await stateFixture(context, 'cursor-ledger-active-cap-'); + const stateDir = path.join(root, 'state'); + const ledger = new SubmissionLedger({ stateDir }); + await ledger.init(); + const current = new Date().toISOString(); + const records = [ + validRecord({ requestId: 'active-pending-first', updatedAt: current, status: 'pending' }), + ...Array.from({ length: 510 }, (_, index) => validRecord({ requestId: `terminal-${index.toString().padStart(3, '0')}`, status: 'completed' })), + validRecord({ requestId: 'active-uncertain-last', status: 'uncertain', agentId: 'agent-active', providerAgentId: 'agent-active' }), + ]; + await writeFile(path.join(stateDir, 'submissions.json'), JSON.stringify({ version: 1, records }), { mode: 0o600 }); + + const restarted = new SubmissionLedger({ stateDir }); + assert.equal((await restarted.lookup('active-pending-first')).status, 'pending'); + assert.equal((await restarted.lookup('active-uncertain-last')).status, 'uncertain'); + assert.equal((await restarted.lookup('terminal-000')), null); + assert.equal((await restarted.lookup('terminal-509')).status, 'completed'); + + await restarted.begin({ requestId: 'cap-trigger-write', kind: 'agents.create', digest }); + const persisted = JSON.parse(await readFile(path.join(stateDir, 'submissions.json'), 'utf8')).records; + assert.ok(persisted.some((record) => record.requestId === 'active-pending-first')); + assert.ok(persisted.some((record) => record.requestId === 'active-uncertain-last')); + assert.ok(persisted.filter((record) => !['pending', 'uncertain'].includes(record.status)).length <= 500); +}); + +test('retry clears prior reconciliation metadata before starting a new attempt', async (context) => { + const root = await stateFixture(context, 'cursor-ledger-retry-metadata-'); + const stateDir = path.join(root, 'state'); + const ledger = new SubmissionLedger({ stateDir }); + const providerAgentId = 'agent-retry-metadata'; + await ledger.begin({ requestId: 'retry-metadata-1', kind: 'agents.create', digest, agentId: providerAgentId, providerAgentId }); + await ledger.uncertain('retry-metadata-1', { + agentId: providerAgentId, + providerAgentId, + reconciliationReason: 'old-observation', + reconciliationRequired: true, + reconciledAt: '2026-08-17T00:01:00.000Z', + staleAt: '2026-08-17T00:01:00.000Z', + recoveryReason: 'stale_pending', + failureCode: 'old_failure', + providerCode: 'old_provider_code', + }); + await ledger.reconcile('retry-metadata-1', { agentId: providerAgentId }); + const retry = await ledger.begin({ requestId: 'retry-metadata-1', kind: 'agents.create', digest, agentId: providerAgentId, providerAgentId }); + assert.equal(retry.record.status, 'pending'); + for (const field of ['reconciliationReason', 'reconciliationRequired', 'reconciledAt', 'staleAt', 'recoveryReason', 'failureCode', 'providerCode']) { + assert.equal(Object.hasOwn(retry.record, field), false, field); + } +}); + test('a read queued during begin persistence cannot clear the in-flight record', async (context) => { const root = await stateFixture(context, 'cursor-ledger-read-race-'); const stateDir = path.join(root, 'state'); @@ -267,6 +353,71 @@ test('a live lock fails closed after the bounded wait instead of overwriting sta await assert.rejects(readFile(path.join(stateDir, 'submissions.json'), 'utf8'), { code: 'ENOENT' }); }); +test('a stale ownerless lock is reclaimed only after its age and directory identity remain stable', async (context) => { + const root = await stateFixture(context, 'cursor-ledger-ownerless-stale-'); + const stateDir = path.join(root, 'state'); + await new SubmissionLedger({ stateDir }).init(); + const lockDir = path.join(stateDir, 'submissions.lock'); + await mkdir(lockDir, { mode: 0o700 }); + const old = new Date(Date.now() - 60_000); + await utimes(lockDir, old, old); + + const ledger = new SubmissionLedger({ stateDir, lockTimeoutMs: 100, lockRetryMs: 5, lockStaleMs: 1_000 }); + const result = await ledger.begin({ requestId: 'ownerless-stale', kind: 'agents.create', digest }); + assert.equal(result.duplicate, false); + assert.equal((await ledger.lookup('ownerless-stale')).status, 'pending'); + await ledger.fail('ownerless-stale', { failureCode: 'test-cleanup' }); + await assert.rejects(lstat(lockDir), { code: 'ENOENT' }); +}); + +test('a stale malformed lock owner marker is reclaimable without weakening fresh-lock protection', async (context) => { + const root = await stateFixture(context, 'cursor-ledger-malformed-lock-'); + const stateDir = path.join(root, 'state'); + await new SubmissionLedger({ stateDir }).init(); + const lockDir = path.join(stateDir, 'submissions.lock'); + await mkdir(lockDir, { mode: 0o700 }); + await writeFile(path.join(lockDir, 'owner.json'), '{not-json', { mode: 0o600 }); + const old = new Date(Date.now() - 60_000); + await utimes(lockDir, old, old); + + const ledger = new SubmissionLedger({ stateDir, lockTimeoutMs: 100, lockRetryMs: 5, lockStaleMs: 1_000 }); + const result = await ledger.begin({ requestId: 'malformed-stale', kind: 'agents.create', digest }); + assert.equal(result.duplicate, false); + await ledger.fail('malformed-stale', { failureCode: 'test-cleanup' }); + await assert.rejects(lstat(lockDir), { code: 'ENOENT' }); + + const freshLockDir = path.join(stateDir, 'submissions.lock'); + await mkdir(freshLockDir, { mode: 0o700 }); + await writeFile(path.join(freshLockDir, 'owner.json'), '{still-writing', { mode: 0o600 }); + const blocked = new SubmissionLedger({ stateDir, lockTimeoutMs: 40, lockRetryMs: 5, lockStaleMs: 60_000 }); + await assertLedgerError( + () => blocked.begin({ requestId: 'fresh-malformed', kind: 'agents.create', digest }), + 'ledger_lock_timeout', + ); + assert.equal((await lstat(freshLockDir)).isDirectory(), true); +}); + +test('independent processes contend on a stale lock without deleting the replacement owner marker', async (context) => { + const root = await stateFixture(context, 'cursor-ledger-independent-lock-race-'); + const stateDir = path.join(root, 'state'); + await new SubmissionLedger({ stateDir }).init(); + const lockDir = path.join(stateDir, 'submissions.lock'); + await mkdir(lockDir, { mode: 0o700 }); + await writeFile(path.join(lockDir, 'owner.json'), JSON.stringify({ token: 'dead-owner', pid: 999_999, createdAt: Date.now() }), { mode: 0o600 }); + const old = new Date(Date.now() - 60_000); + await utimes(lockDir, old, old); + + const [first, second] = await Promise.all([ + runLedgerChild(stateDir, 'independent-child-1'), + runLedgerChild(stateDir, 'independent-child-2'), + ]); + assert.equal(first.code, 0, first.stderr); + assert.equal(second.code, 0, second.stderr); + const records = JSON.parse(await readFile(path.join(stateDir, 'submissions.json'), 'utf8')).records; + assert.deepEqual(records.map((record) => record.requestId).sort(), ['independent-child-1', 'independent-child-2']); + await assert.rejects(lstat(lockDir), { code: 'ENOENT' }); +}); + test('state-directory resolution honors explicit and shared configuration before XDG/HOME', async (context) => { const root = await stateFixture(context); assert.deepEqual(resolveStateDirectory({ CURSOR_CLOUD_CONTROL_STATE_DIR: path.join(root, 'explicit'), HOME: root }), { diff --git a/plugins/cursor-cloud-control/test/lifecycle.test.mjs b/plugins/cursor-cloud-control/test/lifecycle.test.mjs index 842ee7a..57d75e2 100644 --- a/plugins/cursor-cloud-control/test/lifecycle.test.mjs +++ b/plugins/cursor-cloud-control/test/lifecycle.test.mjs @@ -9,6 +9,7 @@ import { CursorCloudService, handleToolCall } from '../mcp/server.mjs'; const agentId = 'bc-00000000-0000-0000-0000-000000000001'; const runId = 'run-00000000-0000-0000-0000-000000000001'; +const otherAgentId = 'bc-00000000-0000-0000-0000-000000000002'; function jsonResponse(value = {}, { status = 200 } = {}) { return new Response(JSON.stringify(value), { @@ -117,6 +118,8 @@ class LifecycleClient { } async cancelRun(id, run) { return this.mutate('cancelRun', id, run); } + async getAgent(id) { this.calls.push(['getAgent', id]); return { id, archived: true }; } + async getRun(id, run) { this.calls.push(['getRun', id, run]); return { id: run, agentId: id, status: 'CANCELLED' }; } async archive(id) { return this.mutate('archive', id); } async unarchive(id) { return this.mutate('unarchive', id); } async deleteAgent(id) { return this.mutate('deleteAgent', id); } @@ -187,6 +190,84 @@ test('service dispatches archive, unarchive, cancel, and delete once with exact ]); }); +test('lifecycle and cancellation request IDs deduplicate successful mutations durably', async (context) => { + const { client, service } = await serviceFixture(context); + const archived = await handleToolCall('lifecycle', { action: 'archive', requestId: 'lifecycle-dedupe-1', agentId }, service); + const duplicate = await handleToolCall('lifecycle', { action: 'archive', requestId: 'lifecycle-dedupe-1', agentId }, service); + assert.equal(archived.structuredContent.receipt.duplicate, false); + assert.equal(duplicate.structuredContent.receipt.duplicate, true); + assert.equal(client.calls.filter(([name]) => name === 'archive').length, 1); + + const cancelled = await handleToolCall('runs', { action: 'cancel', requestId: 'cancel-dedupe-1', agentId, runId }, service); + const cancelDuplicate = await handleToolCall('runs', { action: 'cancel', requestId: 'cancel-dedupe-1', agentId, runId }, service); + assert.equal(cancelled.structuredContent.receipt.duplicate, false); + assert.equal(cancelDuplicate.structuredContent.receipt.duplicate, true); + assert.equal(client.calls.filter(([name]) => name === 'cancelRun').length, 1); +}); + +test('uncertain lifecycle mutations reconcile or explicitly release without resubmitting', async (context) => { + const { client, service } = await serviceFixture(context); + client.failMutation = true; + const args = { action: 'archive', requestId: 'lifecycle-reconcile-1', agentId }; + const first = await handleToolCall('lifecycle', args, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + client.failMutation = false; + const reconciled = await handleToolCall('lifecycle', { action: 'reconcile', requestId: args.requestId, agentId }, service); + assert.equal(reconciled.structuredContent.ok, true); + assert.equal(reconciled.structuredContent.provider.reservation, 'completed'); + assert.equal(client.calls.filter(([name]) => name === 'archive').length, 1); + + client.failMutation = true; + const releaseArgs = { action: 'unarchive', requestId: 'lifecycle-release-1', agentId }; + await handleToolCall('lifecycle', releaseArgs, service); + const released = await handleToolCall('lifecycle', { + action: 'reconcile', requestId: releaseArgs.requestId, release: true, confirmation: `release:${releaseArgs.requestId}`, + }, service); + assert.equal(released.structuredContent.ok, true); + assert.equal(released.structuredContent.provider.reservation, 'released'); + assert.equal(client.calls.filter(([name]) => name === 'unarchive').length, 1); +}); + +test('lifecycle reconciliation rejects a mismatched provider agent without releasing', async (context) => { + const { client, service } = await serviceFixture(context); + client.failMutation = true; + const args = { action: 'archive', requestId: 'lifecycle-agent-identity-mismatch-1', agentId }; + const first = await handleToolCall('lifecycle', args, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + client.failMutation = false; + client.getAgent = async (requestedAgentId) => { + client.calls.push(['getAgent', requestedAgentId]); + return { id: otherAgentId, archived: true }; + }; + const mismatch = await handleToolCall('lifecycle', { action: 'reconcile', requestId: args.requestId, agentId }, service); + assert.equal(mismatch.structuredContent.error.code, 'uncertain_submission'); + const record = await service.ledger.lookup(args.requestId); + assert.equal(record.status, 'uncertain'); + assert.equal(mismatch.structuredContent.error.details.providerReturnedAgentId, otherAgentId); + assert.equal(client.calls.filter(([name]) => name === 'archive').length, 1); +}); + +test('uncertain archive 404 reconciliation uses the exact stored provider target', async (context) => { + const { client, service } = await serviceFixture(context); + client.failMutation = true; + const args = { action: 'archive', requestId: 'lifecycle-reconcile-404-1', agentId }; + const first = await handleToolCall('lifecycle', args, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + const before = await service.ledger.lookup(args.requestId); + assert.equal(before.agentId, agentId); + assert.equal(before.providerAgentId, null); + + client.failMutation = false; + client.getAgent = async (id) => { + client.calls.push(['getAgent', id]); + throw new CursorApiError('not_found', 'missing agent', { status: 404 }); + }; + const reconciled = await handleToolCall('lifecycle', { action: 'reconcile', requestId: args.requestId, agentId }, service); + assert.equal(reconciled.structuredContent.ok, true); + assert.equal(reconciled.structuredContent.provider.reservation, 'released'); + assert.equal((await service.ledger.lookup(args.requestId)).providerAgentId, agentId); +}); + test('lifecycle delete requires exact confirmation and never contacts Cursor on rejection', async (context) => { const { client, service } = await serviceFixture(context); for (const confirmation of [agentId, `delete:${agentId}:extra`, `delete:bc-00000000-0000-0000-0000-000000000002`]) { @@ -218,6 +299,23 @@ test('service exposes usage and artifact list/download lifecycle without mutatio ]); }); +test('artifact list exposes provider and local page truncation while retaining the hard bound', async (context) => { + const { client, service } = await serviceFixture(context); + const sourceItems = Array.from({ length: 205 }, (_, index) => ({ + path: `artifacts/output-${index}.txt`, + sizeBytes: index, + })); + client.artifacts = async () => ({ items: sourceItems, truncated: true }); + + const listed = await handleToolCall('artifacts', { action: 'list', agentId }, service); + assert.equal(listed.structuredContent.ok, true); + assert.equal(listed.structuredContent.artifacts.items.length, 200); + assert.equal(listed.structuredContent.artifacts.items.at(-1).path, 'artifacts/output-199.txt'); + assert.equal(listed.structuredContent.artifacts.truncated, true); + assert.equal(listed.structuredContent.artifacts.pageTruncated, true); + assert.equal(Object.hasOwn(listed.structuredContent.artifacts, 'output-200'), false); +}); + test('service does not auto-retry failed lifecycle mutations', async (context) => { const { client, service } = await serviceFixture(context); client.failMutation = true; @@ -231,7 +329,7 @@ test('service does not auto-retry failed lifecycle mutations', async (context) = for (const [tool, arguments_, operation] of operations) { const result = await handleToolCall(tool, arguments_, service); assert.equal(result.isError, true); - assert.equal(result.structuredContent.error.code, 'upstream_failure'); + assert.equal(result.structuredContent.error.code, 'uncertain_submission'); assert.equal(client.calls.filter(([name]) => name === operation).length, 1, `${operation} was retried`); } }); diff --git a/plugins/cursor-cloud-control/test/local.test.mjs b/plugins/cursor-cloud-control/test/local.test.mjs index 217d725..9a1116f 100644 --- a/plugins/cursor-cloud-control/test/local.test.mjs +++ b/plugins/cursor-cloud-control/test/local.test.mjs @@ -1,14 +1,18 @@ import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, utimes, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { Readable, Writable } from 'node:stream'; import test from 'node:test'; import { CursorLocalService, FOUNDATION_TOOLS, + HOST_TRUSTED_RUNS_ENV, LocalRunLedger, + MAX_LOCAL_LEDGER_RECORDS, SERVER_IDENTITY, TOOLS, buildArguments, @@ -16,9 +20,43 @@ import { projectAuth, resolveBinary, runStdio, + terminateProcessGroup, + toolsForEnvironment, validateToolInput, } from '../mcp/local.mjs'; +const LOCAL_MODULE_URL = new URL('../mcp/local.mjs', import.meta.url).href; +const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + +function childResult(child) { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve({ code: child.exitCode, signal: child.signalCode, stdout: '', stderr: '' }); + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk) => { stdout += chunk; }); + child.stderr?.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal, stdout, stderr })); + }); +} + +async function waitForFile(file, timeoutMs = 2_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { return await readFile(file, 'utf8'); } catch {} + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Timed out waiting for ${file}`); +} + +function ledgerChild(state, script, extra = {}) { + return spawn(process.execPath, ['--input-type=module', '-e', script, state], { + cwd: REPOSITORY_ROOT, + env: { ...process.env, LOCAL_MODULE_URL, ...extra }, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + async function fixture(context) { const root = await mkdtemp(path.join(os.tmpdir(), 'cursor-local-control-test-')); context.after(() => rm(root, { recursive: true, force: true })); @@ -63,12 +101,16 @@ exit 0 return { root, home, config, state, workspace, binary, env }; } -test('local MCP identity exposes status while retaining a deferred foundation', () => { - assert.deepEqual(SERVER_IDENTITY, { name: 'cursor-local-control', version: '0.1.0' }); +test('local MCP identity keeps host-trusted execution opt-in', () => { + assert.deepEqual(SERVER_IDENTITY, { name: 'cursor-local-control', version: '0.2.0' }); assert.deepEqual(FOUNDATION_TOOLS.map((tool) => tool.name), ['status', 'run', 'runs']); assert.deepEqual(TOOLS.map((tool) => tool.name), ['status']); + assert.deepEqual(toolsForEnvironment({}), TOOLS); + assert.deepEqual(toolsForEnvironment({ [HOST_TRUSTED_RUNS_ENV]: '1' }), FOUNDATION_TOOLS); assert.throws(() => validateToolInput('runs', { action: 'get', localRunId: 'run-000000000' }), /invalid format/); assert.throws(() => validateToolInput('run', { workspace: 'relative', prompt: 'x', mode: 'read_only' }), /absolute/); + assert.throws(() => validateToolInput('run', { workspace: '/tmp', prompt: 'x', mode: 'read_only' }), /execution_profile/); + assert.throws(() => validateToolInput('run', { workspace: '/tmp', prompt: 'x', mode: 'read_only', execution_profile: 'host_trusted' }), /requestId/); assert.match(resolveBinary({ HOME: '/tmp', CURSOR_LOCAL_CLI_BIN: '/tmp/agent' }).reason, /generic agent/); }); @@ -80,19 +122,170 @@ test('local argument policy keeps read-only and implement invocation distinct', assert.ok(implement.includes('--force')); assert.equal(readOnly.includes('--worktree'), false); assert.ok(implement.includes('--worktree')); - assert.ok(readOnly.includes('--sandbox') && readOnly.includes('enabled')); + assert.ok(readOnly.includes('--sandbox') && readOnly.includes('disabled')); + assert.equal(readOnly.at(-2), '--'); + assert.equal(readOnly.at(-1), 'inspect'); + assert.equal(buildArguments({ workspace: '/tmp/workspace', prompt: '--endpoint=https://attacker.invalid --plugin-dir=/tmp', mode: 'read_only', worktreeName: 'test-ro' }).at(-1), '--endpoint=https://attacker.invalid --plugin-dir=/tmp'); }); test('NDJSON collector bounds events and redacts local credentials', () => { const seen = []; const collector = createNdjsonCollector({ maxEvents: 1, maxBytes: 2000, secrets: ['local-secret'], onEvent: (event) => seen.push(event) }); collector.push('{"type":"assistant","message":{"content":[{"type":"text","text":"local-secret"}]}}\n'); - collector.push('{"type":"result","result":"second"}\n'); + collector.push('{"type":"result","result":"second"}\n{"type":"assistant","message":{"content":[{"type":"text","text":"overflow"}]}}\n'.repeat(1000)); + collector.push('{"type":"result","result":"after-cap"}\n'); const result = collector.finish(); assert.equal(result.events.length, 1); assert.equal(result.truncated, true); assert.equal(JSON.stringify(result).includes('local-secret'), false); - assert.equal(seen.length, 2); + assert.equal(seen.length, 1); +}); + +test('NDJSON collector preserves split UTF-8 and fails closed on invalid bytes', () => { + const source = Buffer.from('{"type":"assistant","message":{"content":[{"type":"text","text":"héllo 😀"}]}}\n', 'utf8'); + const collector = createNdjsonCollector(); + for (const byte of source) collector.push(Buffer.from([byte])); + const result = collector.finish(); + assert.equal(result.events[0].text, 'héllo 😀'); + assert.equal(result.invalidUtf8, false); + + const invalid = createNdjsonCollector(); + invalid.push(Buffer.from('{"type":"assistant","message":{"content":[{"type":"text","text":"', 'utf8')); + invalid.push(Buffer.from([0xc3])); + invalid.push(Buffer.from('"}]}}\n', 'utf8')); + const invalidResult = invalid.finish(); + assert.equal(invalidResult.events.length, 0); + assert.equal(invalidResult.invalidUtf8, true); + assert.equal(invalidResult.truncated, true); +}); + +test('NDJSON collector enforces exact byte boundaries and unterminated-line bounds', () => { + const exact = createNdjsonCollector({ maxBytes: 2 }); + exact.push(Buffer.from('{}\n')); + assert.deepEqual(exact.finish(), { events: [{ type: 'unknown' }], bytes: 2, truncated: false, invalidUtf8: false }); + + const oversized = createNdjsonCollector({ maxBytes: 5_000_000 }); + oversized.push(Buffer.alloc(256 * 1024 + 1, 0x61)); + const oversizedResult = oversized.finish(); + assert.equal(oversizedResult.events.length, 0); + assert.equal(oversizedResult.truncated, true); +}); + +test('LocalRunLedger serializes independent MCP processes without lost updates', async (context) => { + const fixtureValue = await fixture(context); + const script = ` + const { LocalRunLedger } = await import(process.env.LOCAL_MODULE_URL); + const ledger = new LocalRunLedger({ stateDir: process.argv[1], lockTimeoutMs: 5000, staleLockMs: 25 }); + await ledger.add({ localRunId: 'lrun-child-' + process.env.CHILD_INDEX.padStart(8, '0'), requestId: 'child-request-' + process.env.CHILD_INDEX.padStart(8, '0'), requestDigest: process.env.CHILD_INDEX, lifecycle: 'accepted' }); + `; + const children = Array.from({ length: 8 }, (_, index) => ledgerChild(fixtureValue.state, script, { CHILD_INDEX: String(index) })); + const results = await Promise.all(children.map(childResult)); + assert.ok(results.every((result) => result.code === 0), results.map((result) => result.stderr).join('\n')); + const persisted = await new LocalRunLedger({ stateDir: fixtureValue.state }).read(); + assert.equal(persisted.runs.length, 8); + assert.deepEqual(new Set(persisted.runs.map((entry) => entry.requestId)).size, 8); +}); + +test('LocalRunLedger recovers a crashed owner and stale lock directory', async (context) => { + const fixtureValue = await fixture(context); + const holdScript = ` + const { LocalRunLedger } = await import(process.env.LOCAL_MODULE_URL); + const { writeFile } = await import('node:fs/promises'); + const ledger = new LocalRunLedger({ stateDir: process.argv[1], lockTimeoutMs: 5000, staleLockMs: 60000 }); + await ledger.withLock(async () => { await writeFile(process.env.READY_FILE, 'locked'); await new Promise(() => {}); }); + `; + const readyFile = path.join(fixtureValue.root, 'holder-ready'); + const holder = ledgerChild(fixtureValue.state, holdScript, { READY_FILE: readyFile }); + await waitForFile(readyFile); + holder.kill('SIGKILL'); + await childResult(holder); + const recovered = new LocalRunLedger({ stateDir: fixtureValue.state, lockTimeoutMs: 1000, staleLockMs: 60000 }); + await recovered.add({ localRunId: 'lrun-recovered-00001', requestId: 'recovered-request-1', requestDigest: 'recovered', lifecycle: 'accepted' }); + assert.ok((await recovered.find('lrun-recovered-00001'))); + + await mkdir(path.join(fixtureValue.state, 'runs.lock'), { mode: 0o700 }); + const old = new Date(Date.now() - 10_000); + await utimes(path.join(fixtureValue.state, 'runs.lock'), old, old); + const staleRecovered = new LocalRunLedger({ stateDir: fixtureValue.state, lockTimeoutMs: 1000, staleLockMs: 1 }); + await staleRecovered.add({ localRunId: 'lrun-stale-recovered-0001', requestId: 'stale-recovered-1', requestDigest: 'stale', lifecycle: 'accepted' }); + assert.ok((await staleRecovered.find('lrun-stale-recovered-0001'))); +}); + +test('LocalRunLedger preserves 201+ request tombstones across restart and fails closed at capacity', async (context) => { + const fixtureValue = await fixture(context); + const maxRecords = 256; + const records = Array.from({ length: 201 }, (_, index) => ({ + localRunId: `lrun-terminal-${String(index).padStart(8, '0')}`, + requestId: `terminal-request-${String(index).padStart(8, '0')}`, + requestDigest: `terminal-digest-${index}`, + lifecycle: 'terminal', + terminalState: 'succeeded', + })); + const ledger = new LocalRunLedger({ stateDir: fixtureValue.state, maxRecords }); + await ledger.write({ version: 1, runs: records }); + + const restarted = new LocalRunLedger({ stateDir: fixtureValue.state, maxRecords }); + const oldest = await restarted.findRequest(records[0].requestId); + assert.equal(oldest.requestDigest, records[0].requestDigest); + const duplicate = await restarted.add({ ...records[0], localRunId: 'lrun-retry-00000001' }); + assert.equal(duplicate.localRunId, records[0].localRunId); + + for (let index = records.length; index < maxRecords; index += 1) { + await restarted.add({ + localRunId: `lrun-terminal-${String(index).padStart(8, '0')}`, + requestId: `terminal-request-${String(index).padStart(8, '0')}`, + requestDigest: `terminal-digest-${index}`, + lifecycle: 'terminal', + terminalState: 'succeeded', + }); + } + await assert.rejects( + restarted.add({ localRunId: 'lrun-capacity-0000001', requestId: 'capacity-request-0001', requestDigest: 'capacity', lifecycle: 'accepted' }), + /capacity|durable reservations/i, + ); + + const capacityState = path.join(fixtureValue.root, 'capacity-state'); + const capacityLedger = new LocalRunLedger({ stateDir: capacityState, maxRecords: 1 }); + await capacityLedger.add({ + localRunId: 'lrun-capacity-existing', + requestId: 'capacity-existing-0001', + requestDigest: 'existing', + lifecycle: 'terminal', + terminalState: 'succeeded', + }); + let spawnCalls = 0; + const service = new CursorLocalService({ + env: { ...fixtureValue.env, [HOST_TRUSTED_RUNS_ENV]: '1' }, + ledger: capacityLedger, + spawnImpl: () => { spawnCalls += 1; throw new Error('spawn must not be reached at ledger capacity'); }, + }); + await assert.rejects(service.call('run', { + workspace: fixtureValue.workspace, + prompt: 'capacity check', + mode: 'read_only', + execution_profile: 'host_trusted', + requestId: 'capacity-new-request-0001', + }), /capacity|durable reservations/i); + assert.equal(spawnCalls, 0); + assert.ok(MAX_LOCAL_LEDGER_RECORDS >= maxRecords); +}); + +test('process-group termination rejects a reused PID before TERM/KILL', async () => { + const originalKill = process.kill; + const signals = []; + process.kill = (pid, signal) => { + if (signal !== 0) signals.push({ pid, signal }); + return true; + }; + try { + await assert.rejects( + terminateProcessGroup({ pid: process.pid }, { startToken: 'pid-reuse-does-not-match', graceMs: 0 }), + /identity changed/i, + ); + } finally { + process.kill = originalKill; + } + assert.deepEqual(signals, []); }); test('auth projection discards account identity and returns only compact state', () => { @@ -101,7 +294,7 @@ test('auth projection discards account identity and returns only compact state', assert.equal(JSON.stringify(projected).includes('ada@example.test'), false); }); -test('status reports local state and fail-closed sandbox without spawning a provider child', async (context) => { +test('status reports local state and keeps host-trusted execution disabled by default', async (context) => { const fixtureValue = await fixture(context); const ledger = new LocalRunLedger({ stateDir: fixtureValue.state, source: 'environment' }); const service = new CursorLocalService({ env: fixtureValue.env, ledger }); @@ -130,27 +323,286 @@ test('status reports local state and fail-closed sandbox without spawning a prov workspace: fixtureValue.workspace, prompt: 'inspect fixture', mode: 'read_only', + execution_profile: 'host_trusted', requestId: 'local-request-0001', waitMs: 5_000, - }), /sandbox/); + }), /host-trusted/i); assert.equal(spawnCalls, 0); await assert.rejects(guardedService.call('run', { workspace: fixtureValue.workspace, prompt: 'inspect fixture', mode: 'read_only', - }), /foundation_not_exposed|deferred/); + execution_profile: 'host_trusted', + }), /disabled|foundation_not_exposed/i); assert.equal(spawnCalls, 0); await assert.rejects(readFile(path.join(fixtureValue.state, 'runs.json'), 'utf8'), { code: 'ENOENT' }); }); -test('run fails closed when read-only permission deny is missing', async (context) => { +test('host-trusted read-only execution requires explicit provider deny rules', async (context) => { const fixtureValue = await fixture(context); await writeFile(path.join(fixtureValue.config, 'cli-config.json'), JSON.stringify({ version: 1, permissions: { allow: [], deny: ['Mcp(*:*)'] } }), { mode: 0o600 }); - const service = new CursorLocalService({ env: fixtureValue.env, ledger: new LocalRunLedger({ stateDir: fixtureValue.state }) }); - await assert.rejects(service.verifyRunEnvironment({ workspace: fixtureValue.workspace, prompt: 'x', mode: 'read_only' }), /Write\(\*\*\)/); + const env = { ...fixtureValue.env, [HOST_TRUSTED_RUNS_ENV]: '1' }; + const service = new CursorLocalService({ env, ledger: new LocalRunLedger({ stateDir: fixtureValue.state }) }); + await assert.rejects( + service.verifyRunEnvironment({ workspace: fixtureValue.workspace, prompt: 'x', mode: 'read_only', execution_profile: 'host_trusted' }), + /Write\(\*\*\)/, + ); + const implementEnvironment = await service.verifyRunEnvironment({ workspace: fixtureValue.workspace, prompt: 'x', mode: 'implement', execution_profile: 'host_trusted' }); + assert.equal(implementEnvironment.boundary, 'host_trusted'); +}); + +test('host-trusted implement rejects unrestricted approval and missing MCP deny rules', async (context) => { + const fixtureValue = await fixture(context); + const env = { ...fixtureValue.env, [HOST_TRUSTED_RUNS_ENV]: '1' }; + await writeFile(path.join(fixtureValue.config, 'cli-config.json'), JSON.stringify({ + version: 1, + approvalMode: 'unrestricted', + permissions: { allow: [], deny: ['Write(**)', 'Shell(*)'] }, + }), { mode: 0o600 }); + const service = new CursorLocalService({ env, ledger: new LocalRunLedger({ stateDir: fixtureValue.state }) }); + await assert.rejects( + service.verifyRunEnvironment({ workspace: fixtureValue.workspace, prompt: 'x', mode: 'implement', execution_profile: 'host_trusted' }), + /Unrestricted Cursor CLI approval mode/, + ); + await writeFile(path.join(fixtureValue.config, 'cli-config.json'), JSON.stringify({ + version: 1, + approvalMode: 'allowlist', + permissions: { allow: [], deny: ['Write(**)', 'Shell(*)'] }, + }), { mode: 0o600 }); + await assert.rejects( + service.verifyRunEnvironment({ workspace: fixtureValue.workspace, prompt: 'x', mode: 'implement', execution_profile: 'host_trusted' }), + /Mcp\(\*:\*\)/, + ); +}); + +test('host-trusted runs reject group-writable home and config directories', async (context) => { + const fixtureValue = await fixture(context); + const env = { ...fixtureValue.env, [HOST_TRUSTED_RUNS_ENV]: '1' }; + await chmod(fixtureValue.home, 0o750); + const service = new CursorLocalService({ env, ledger: new LocalRunLedger({ stateDir: fixtureValue.state }) }); + await assert.rejects( + service.verifyRunEnvironment({ workspace: fixtureValue.workspace, prompt: 'x', mode: 'implement', execution_profile: 'host_trusted' }), + /owner-only|0700/, + ); + await chmod(fixtureValue.home, 0o700); + await chmod(fixtureValue.config, 0o750); + await assert.rejects( + service.verifyRunEnvironment({ workspace: fixtureValue.workspace, prompt: 'x', mode: 'read_only', execution_profile: 'host_trusted' }), + /permission config|owner-only|0700/, + ); +}); + +test('explicit relative Cursor config directory fails closed instead of falling back to HOME', async (context) => { + const fixtureValue = await fixture(context); + const env = { ...fixtureValue.env, [HOST_TRUSTED_RUNS_ENV]: '1', CURSOR_LOCAL_CLI_CONFIG_DIR: 'relative-config' }; + const service = new CursorLocalService({ env, ledger: new LocalRunLedger({ stateDir: fixtureValue.state }) }); + await assert.rejects( + service.verifyRunEnvironment({ workspace: fixtureValue.workspace, prompt: 'x', mode: 'read_only', execution_profile: 'host_trusted' }), + /CONFIG_DIR|config directory|configuration/i, + ); +}); + +test('host-trusted read-only rejects a symlinked project Cursor config directory', async (context) => { + const fixtureValue = await fixture(context); + const external = path.join(fixtureValue.root, 'external-project-config'); + await mkdir(external, { mode: 0o700 }); + await writeFile(path.join(external, 'cli.json'), JSON.stringify({ + permissions: { allow: [], deny: ['Write(**)', 'Shell(*)', 'Mcp(*:*)'] }, + }), { mode: 0o600 }); + await symlink(external, path.join(fixtureValue.workspace, '.cursor')); + const env = { ...fixtureValue.env, [HOST_TRUSTED_RUNS_ENV]: '1' }; + const service = new CursorLocalService({ env, ledger: new LocalRunLedger({ stateDir: fixtureValue.state }) }); + await assert.rejects( + service.verifyRunEnvironment({ workspace: fixtureValue.workspace, prompt: 'x', mode: 'read_only', execution_profile: 'host_trusted' }), + /project \.cursor directory must be a real directory|invalid or not owner-only/, + ); +}); + +test('host-trusted read-only rejects unrestricted project approval mode', async (context) => { + const fixtureValue = await fixture(context); + const projectDirectory = path.join(fixtureValue.workspace, '.cursor'); + await mkdir(projectDirectory, { mode: 0o700 }); + await writeFile(path.join(projectDirectory, 'cli.json'), JSON.stringify({ + approvalMode: 'unrestricted', + permissions: { allow: [], deny: ['Write(**)', 'Shell(*)', 'Mcp(*:*)'] }, + }), { mode: 0o600 }); + const env = { ...fixtureValue.env, [HOST_TRUSTED_RUNS_ENV]: '1' }; + const service = new CursorLocalService({ env, ledger: new LocalRunLedger({ stateDir: fixtureValue.state }) }); + await assert.rejects( + service.verifyRunEnvironment({ workspace: fixtureValue.workspace, prompt: 'x', mode: 'read_only', execution_profile: 'host_trusted' }), + /Unrestricted Cursor CLI approval mode/, + ); +}); + +test('host-trusted run invokes cursor-agent directly with bounded, honest receipts', async (context) => { + const fixtureValue = await fixture(context); + const env = { + ...fixtureValue.env, + [HOST_TRUSTED_RUNS_ENV]: '1', + CURSOR_LOCAL_CLI_API_KEY: 'local-secret', + CURSOR_API_KEY: 'cloud-secret-must-not-cross', + }; + let spawned; + const service = new CursorLocalService({ + env, + ledger: new LocalRunLedger({ stateDir: fixtureValue.state }), + spawnImpl: (...args) => { spawned = args; return spawn(...args); }, + }); + const result = await service.call('run', { + workspace: fixtureValue.workspace, + prompt: 'inspect fixture', + mode: 'read_only', + execution_profile: 'host_trusted', + requestId: 'host-trusted-read-only-0001', + waitMs: 5_000, + maxEvents: 10, + maxBytes: 20_000, + }); + assert.equal(result.ok, true); + assert.equal(spawned[0], fixtureValue.binary); + assert.equal(spawned[2].cwd, fixtureValue.workspace); + assert.equal(spawned[2].env.CURSOR_API_KEY, 'local-secret'); + assert.equal(spawned[2].env.CLOUD_API_KEY, undefined); + assert.equal(spawned[2].env.MODEL_API_KEY, undefined); + assert.ok(spawned[1].includes('--sandbox') && spawned[1].includes('disabled')); + assert.equal(spawned[1].includes('--force'), false); + assert.equal(result.receipt.execution.boundary, 'host_trusted'); + assert.equal(result.receipt.execution.authority, 'mcp_process_user'); + assert.equal(result.receipt.execution.outerSandbox, 'none'); + assert.equal(result.receipt.execution.providerSandbox, 'disabled'); + assert.equal(result.receipt.workspaceChanged, null); + assert.equal(result.receipt.workspaceChangeProof, 'not_attested_host_trusted'); + assert.equal(result.receipt.terminalState, 'succeeded'); + assert.equal(JSON.stringify(result).includes('cloud-secret-must-not-cross'), false); +}); + +test('host-trusted lifecycle fails closed without a valid provider cwd and retains redacted stderr', async (context) => { + const fixtureValue = await fixture(context); + await writeFile(fixtureValue.binary, `#!/bin/sh +if [ "$1" = "--version" ]; then + printf '%s\\n' 'cursor-agent test-no-cwd' + exit 0 +fi +printf '%s\\n' '{"type":"system","subtype":"init"}' +printf 'provider-secret\\n' >&2 +printf '%s\\n' '{"type":"result","subtype":"success","result":"done"}' +exit 0 +`, { mode: 0o700 }); + await chmod(fixtureValue.binary, 0o700); + const env = { + ...fixtureValue.env, + [HOST_TRUSTED_RUNS_ENV]: '1', + CURSOR_LOCAL_CLI_API_KEY: 'provider-secret', + }; + env.CURSOR_LOCAL_CLI_SHA256 = createHash('sha256').update(await readFile(fixtureValue.binary)).digest('hex'); + const service = new CursorLocalService({ env, ledger: new LocalRunLedger({ stateDir: fixtureValue.state }), spawnImpl: (...args) => spawn(...args) }); + const result = await service.call('run', { + workspace: fixtureValue.workspace, + prompt: 'inspect fixture', + mode: 'read_only', + execution_profile: 'host_trusted', + requestId: 'host-trusted-no-cwd-0001', + waitMs: 5_000, + }); + assert.equal(result.receipt.terminalState, 'environment_blocked'); + assert.equal(result.receipt.logs.events.some((event) => event.type === 'stderr' && event.text.includes('provider-secret') === false), true); + assert.equal(JSON.stringify(result).includes('provider-secret'), false); +}); + +test('host-trusted catalog exposes run lifecycle only after administrator activation', async (context) => { + const fixtureValue = await fixture(context); + const output = []; + const outputStream = new Writable({ write(chunk, _encoding, callback) { output.push(chunk.toString()); callback(); } }); + await runStdio({ + input: Readable.from([ + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }), + '', + ].join('\n')), + output: outputStream, + service: new CursorLocalService({ env: { ...fixtureValue.env, [HOST_TRUSTED_RUNS_ENV]: '1' }, ledger: new LocalRunLedger({ stateDir: fixtureValue.state }) }), + }); + const response = JSON.parse(output.join('').trim()); + assert.deepEqual(response.result.tools.map((tool) => tool.name), ['status', 'run', 'runs']); +}); + +test('host-trusted cancellation escalates an owned process group after TERM', async (context) => { + const fixtureValue = await fixture(context); + await writeFile(fixtureValue.binary, `#!/bin/sh +if [ "$1" = "--version" ]; then + printf '%s\\n' 'cursor-agent test-long-running' + exit 0 +fi +if [ "$1" = "status" ]; then + printf '%s\\n' '{"authenticated":true}' + exit 0 +fi +printf '%s\\n' '{"type":"system","subtype":"init","cwd":"'"$HOME"'/.cursor/worktrees/test"}' +trap '' TERM INT +sleep 30 & +wait +`, { mode: 0o700 }); + await chmod(fixtureValue.binary, 0o700); + const env = { + ...fixtureValue.env, + [HOST_TRUSTED_RUNS_ENV]: '1', + }; + env.CURSOR_LOCAL_CLI_SHA256 = createHash('sha256').update(await readFile(fixtureValue.binary)).digest('hex'); + const service = new CursorLocalService({ env, ledger: new LocalRunLedger({ stateDir: fixtureValue.state }) }); + const started = await service.call('run', { + workspace: fixtureValue.workspace, + prompt: 'long-running fixture', + mode: 'read_only', + execution_profile: 'host_trusted', + requestId: 'host-trusted-cancel-0001', + waitMs: 0, + timeoutMs: 30_000, + }); + const cancelled = await service.call('runs', { action: 'cancel', localRunId: started.receipt.localRunId }); + assert.equal(cancelled.ok, true); + assert.equal(cancelled.run.terminalState, 'cancelled'); + assert.equal(cancelled.run.execution.boundary, 'host_trusted'); +}); + +test('host-trusted spawn failure finalizes the local receipt instead of leaving it accepted', async (context) => { + const fixtureValue = await fixture(context); + const env = { ...fixtureValue.env, [HOST_TRUSTED_RUNS_ENV]: '1' }; + const service = new CursorLocalService({ + env, + ledger: new LocalRunLedger({ stateDir: fixtureValue.state }), + spawnImpl: () => { const error = new Error('spawn denied'); error.code = 'EACCES'; throw error; }, + }); + await assert.rejects(service.call('run', { + workspace: fixtureValue.workspace, + prompt: 'spawn failure fixture', + mode: 'implement', + execution_profile: 'host_trusted', + requestId: 'host-trusted-spawn-failure-0001', + }), /start the local Cursor CLI/); + const record = await service.ledger.findRequest('host-trusted-spawn-failure-0001'); + assert.equal(record.lifecycle, 'terminal'); + assert.equal(record.terminalState, 'failed'); + assert.equal(record.error, 'EACCES'); +}); + +test('local service reconciles persisted runs whose owner process disappeared', async (context) => { + const fixtureValue = await fixture(context); + const ledger = new LocalRunLedger({ stateDir: fixtureValue.state }); + await ledger.add({ + localRunId: 'lrun-orphaned-00001', + requestId: 'orphaned-request-1', + requestDigest: 'orphaned-digest', + lifecycle: 'working', + terminalState: null, + execution: { ownerPid: 999999, ownerStart: '1', childPid: null, childStart: null }, + }); + const service = new CursorLocalService({ env: { ...fixtureValue.env, [HOST_TRUSTED_RUNS_ENV]: '1' }, ledger }); + const result = await service.call('runs', { action: 'get', localRunId: 'lrun-orphaned-00001' }); + assert.equal(result.run.lifecycle, 'terminal'); + assert.equal(result.run.terminalState, 'transport_lost'); + assert.equal(result.run.error, 'owner_process_lost'); }); -test('local MCP process exposes status only and rejects deferred foundation calls', async () => { +test('local MCP process exposes status only and rejects unactivated host-trusted calls', async () => { const output = []; const outputStream = new Writable({ write(chunk, _encoding, callback) { output.push(chunk.toString()); callback(); } }); const service = new CursorLocalService({ env: { HOME: '/tmp', CURSOR_LOCAL_CONTROL_STATE_DIR: `/tmp/cursor-local-catalog-${process.pid}` } }); @@ -178,5 +630,6 @@ test('local MCP manifest forwards only administrator pins and omits activation', for (const name of ['CURSOR_LOCAL_CLI_SHA256', 'CURSOR_LOCAL_CLI_SANDBOX_BIN', 'CURSOR_LOCAL_CLI_SANDBOX_SHA256']) { assert.ok(local.env_vars.includes(name)); } + assert.ok(local.env_vars.includes(HOST_TRUSTED_RUNS_ENV)); assert.equal(local.env_vars.includes('CURSOR_LOCAL_CLI_ENABLE_RUNS'), false); }); diff --git a/plugins/cursor-cloud-control/test/server.test.mjs b/plugins/cursor-cloud-control/test/server.test.mjs index bac1881..f8481ac 100644 --- a/plugins/cursor-cloud-control/test/server.test.mjs +++ b/plugins/cursor-cloud-control/test/server.test.mjs @@ -10,6 +10,8 @@ import { CursorCloudService, TOOLS, handleToolCall, projectIdentity, runStdio } const agentId = 'bc-00000000-0000-0000-0000-000000000001'; const runId = 'run-00000000-0000-0000-0000-000000000001'; +const otherAgentId = 'bc-00000000-0000-0000-0000-000000000002'; +const otherRunId = 'run-00000000-0000-0000-0000-000000000002'; class FakeClient { constructor() { @@ -21,6 +23,8 @@ class FakeClient { this.failCreateAgent = null; this.afterCreateAgent = null; this.failFollowup = false; + this.notFoundAgent = false; + this.notFoundRuns = false; this.failRepositories = null; this.modelCatalog = { items: [{ @@ -55,7 +59,8 @@ class FakeClient { this.resolveCreateAgentStarted(); await new Promise((resolve) => { this.releaseCreateAgent = resolve; }); } - const response = { agent: { id: body.agentId, name: 'unit-secret-value', status: 'ACTIVE' }, run: { id: runId, agentId: body.agentId, status: 'CREATING' } }; + const assignedAgentId = body.agentId ?? agentId; + const response = { agent: { id: assignedAgentId, name: 'unit-secret-value', status: 'ACTIVE' }, run: { id: runId, agentId: assignedAgentId, status: 'CREATING' } }; if (this.afterCreateAgent) await this.afterCreateAgent(); return response; } @@ -76,8 +81,16 @@ class FakeClient { async models(options) { this.calls.push(['models', options]); return this.modelCatalog; } async listAgents(query) { this.calls.push(['listAgents', query]); return { items: [{ id: agentId }], nextCursor: 'next' }; } - async getAgent(id) { this.calls.push(['getAgent', id]); return { id, latestRunId: runId }; } - async listRuns(id, query) { this.calls.push(['listRuns', id, query]); return { items: [{ id: runId, agentId: id, status: 'FINISHED' }] }; } + async getAgent(id) { + this.calls.push(['getAgent', id]); + if (this.notFoundAgent) throw new CursorApiError('not_found', 'Cursor API returned HTTP 404.', { status: 404 }); + return { id, latestRunId: runId }; + } + async listRuns(id, query) { + this.calls.push(['listRuns', id, query]); + if (this.notFoundRuns) throw new CursorApiError('not_found', 'Cursor API returned HTTP 404.', { status: 404 }); + return { items: [{ id: runId, agentId: id, status: 'FINISHED' }] }; + } async getRun(id, run) { this.calls.push(['getRun', id, run]); return { id: run, agentId: id, status: 'FINISHED', result: 'done' }; } async cancelRun(id, run) { this.calls.push(['cancelRun', id, run]); return { id: run }; } async usage(id, run) { this.calls.push(['usage', id, run]); return { totalUsage: { totalTokens: 1 }, runs: [{ id: run ?? runId }] }; } @@ -99,7 +112,7 @@ class ConcurrentSecretClient extends FakeClient { if (secret === 'resolved-secret-a') { this.resolveFirstCreateStarted(); await new Promise((resolve) => { this.releaseFirstCreate = resolve; }); - return { agent: { id: body.agentId, detail: secret }, run: { id: runId, agentId: body.agentId, status: 'CREATING' } }; + return { agent: { id: agentId, detail: secret }, run: { id: runId, agentId, status: 'CREATING' } }; } await new Promise((resolve) => setTimeout(resolve, 5)); throw new CursorApiError('bad_request', `provider rejected ${secret}`); @@ -412,6 +425,103 @@ test('create maps official fields, safe defaults, redacted receipts, and dedupli assert.equal(prCall[1].skipReviewerRequest, true); }); +test('a successful create response without a provider agent ID remains uncertain and is never retried', async (context) => { + const { client, service } = await serviceFixture(context); + client.createAgent = async (body) => { + client.calls.push(['createAgent', body]); + return { run: { id: runId, status: 'CREATING' } }; + }; + const args = { action: 'create', requestId: 'create-empty-success-1', prompt: { text: 'response body is incomplete' } }; + const first = await handleToolCall('agents', args, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + assert.equal((await service.ledger.lookup(args.requestId)).status, 'uncertain'); + const second = await handleToolCall('agents', args, service); + assert.equal(second.structuredContent.error.code, 'uncertain_submission'); + assert.equal(client.calls.filter((call) => call[0] === 'createAgent').length, 1); +}); + +test('an explicit create ID does not turn an ID-less 2xx response into a completed receipt', async (context) => { + const { client, service } = await serviceFixture(context); + client.createAgent = async (body) => { + client.calls.push(['createAgent', body]); + return { run: { id: runId, status: 'CREATING' } }; + }; + const args = { + action: 'create', requestId: 'create-explicit-id-empty-success-1', agentId, + prompt: { text: 'provider response omits its ID' }, + }; + const first = await handleToolCall('agents', args, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + const record = await service.ledger.lookup(args.requestId); + assert.equal(record.status, 'uncertain'); + assert.equal(record.providerAgentId, agentId); + assert.equal(record.agentId, null); + const second = await handleToolCall('agents', args, service); + assert.equal(second.structuredContent.error.code, 'uncertain_submission'); + assert.equal(client.calls.filter((call) => call[0] === 'createAgent').length, 1); +}); + +test('provider-assigned create reconciliation never attributes a pre-existing fingerprint match', async (context) => { + const { client, service } = await serviceFixture(context); + client.createAgent = async (body) => { + client.calls.push(['createAgent', body]); + return {}; + }; + client.listAgents = async (query) => { + client.calls.push(['listAgents', query]); + return { items: [{ id: agentId, name: 'recoverable-agent', prompt: 'recover this exact task', model: { id: 'provider-model' } }] }; + }; + const args = { + action: 'create', requestId: 'create-fingerprint-recovery-1', name: 'recoverable-agent', + model: { id: 'provider-model' }, prompt: { text: 'recover this exact task' }, + }; + const first = await handleToolCall('agents', args, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + const reconciled = await handleToolCall('agents', { action: 'reconcile', requestId: args.requestId }, service); + assert.equal(reconciled.structuredContent.error.code, 'uncertain_submission'); + assert.equal((await service.ledger.lookup(args.requestId)).status, 'uncertain'); + assert.equal(client.calls.filter((call) => call[0] === 'createAgent').length, 1); +}); + +test('explicit create ID mismatch remains uncertain and never finalizes the returned agent', async (context) => { + const { client, service } = await serviceFixture(context); + client.createAgent = async (body) => { + client.calls.push(['createAgent', body]); + return { agent: { id: 'bc-00000000-0000-0000-0000-000000000002' }, run: { id: runId, status: 'CREATING' } }; + }; + const args = { + action: 'create', requestId: 'create-provider-id-mismatch-1', agentId, + prompt: { text: 'provider must honor exact requested ID' }, + }; + const first = await handleToolCall('agents', args, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + const record = await service.ledger.lookup(args.requestId); + assert.equal(record.status, 'uncertain'); + assert.equal(record.providerAgentId, agentId); + assert.equal(record.providerReturnedAgentId, 'bc-00000000-0000-0000-0000-000000000002'); + const second = await handleToolCall('agents', args, service); + assert.equal(second.structuredContent.error.code, 'uncertain_submission'); + assert.equal(client.calls.filter((call) => call[0] === 'createAgent').length, 1); +}); + +test('create does not finalize a run returned for a different agent', async (context) => { + const { client, service } = await serviceFixture(context); + client.createAgent = async (body) => { + client.calls.push(['createAgent', body]); + return { agent: { id: agentId }, run: { id: runId, agentId: otherAgentId, status: 'CREATING' } }; + }; + const args = { + action: 'create', requestId: 'create-provider-run-mismatch-1', agentId, + prompt: { text: 'do not bind a cross-agent run' }, + }; + const result = await handleToolCall('agents', args, service); + assert.equal(result.structuredContent.error.code, 'uncertain_submission'); + const record = await service.ledger.lookup(args.requestId); + assert.equal(record.status, 'uncertain'); + assert.equal(record.providerReturnedRunAgentId, otherAgentId); + assert.equal(client.calls.filter((call) => call[0] === 'createAgent').length, 1); +}); + test('create receipts separate requested model while leaving effective model unknown', async (context) => { const { client, service } = await serviceFixture(context); const requested = { id: 'provider-requested', params: [{ id: 'reasoning', value: 'deep' }] }; @@ -612,7 +722,7 @@ test('concurrent MCP secret resolution cannot cross-contaminate delayed success assert.equal(second.structuredContent.error.message, 'provider rejected [REDACTED]'); }); -test('concurrent identical generated-ID creates share one submission and preserve duplicate receipt', async (context) => { +test('concurrent identical provider-assigned creates share one submission and preserve duplicate receipt', async (context) => { const { client, service } = await serviceFixture(context); client.blockCreateAgent = true; const args = { action: 'create', requestId: 'concurrent-create-1', prompt: { text: 'same caller intent' } }; @@ -626,9 +736,10 @@ test('concurrent identical generated-ID creates share one submission and preserv client.releaseCreateAgent(); const first = await firstPromise; + assert.equal(Object.hasOwn(client.calls[0][1], 'agentId'), false, 'generated reservation IDs must not be sent to Cursor'); assert.equal(first.structuredContent.ok, true); assert.equal(first.structuredContent.receipt.duplicate, false); - assert.match(first.structuredContent.receipt.agentId, /^bc-/); + assert.equal(first.structuredContent.receipt.agentId, agentId); const duplicate = await handleToolCall('agents', args, service); assert.equal(duplicate.structuredContent.ok, true); @@ -638,6 +749,32 @@ test('concurrent identical generated-ID creates share one submission and preserv assert.equal(client.calls.filter((call) => call[0] === 'createAgent').length, 1); }); +test('provider-assigned creates omit local IDs and keep different request IDs independent', async (context) => { + const { client, service } = await serviceFixture(context); + client.failCreateAgent = 'upstream_failure'; + const args = { action: 'create', requestId: 'reconcile-assigned-1', prompt: { text: 'provider assigns the ID' } }; + + const first = await handleToolCall('agents', args, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + const record = await service.ledger.lookup(args.requestId); + assert.equal(record.providerAgentId, null); + assert.equal(Object.hasOwn(client.calls[0][1], 'agentId'), false); + + client.failCreateAgent = null; + const newRequest = await handleToolCall('agents', { + action: 'create', requestId: 'reconcile-assigned-2', envVars: {}, prompt: { text: 'do not duplicate' }, + }, service); + assert.equal(newRequest.structuredContent.ok, true); + const missingTarget = await handleToolCall('agents', { action: 'reconcile', requestId: args.requestId }, service); + assert.equal(missingTarget.structuredContent.error.code, 'uncertain_submission'); + assert.equal(client.calls.filter((call) => call[0] === 'getAgent').length, 0); + const attemptedBinding = await handleToolCall('agents', { + action: 'reconcile', requestId: args.requestId, agentId, + }, service); + assert.equal(attemptedBinding.structuredContent.error.code, 'uncertain_submission'); + assert.equal(client.calls.filter((call) => call[0] === 'getAgent').length, 0); +}); + test('definitive create failure leaves a retryable reservation', async (context) => { const { client, service } = await serviceFixture(context); client.failCreateAgent = 'bad_request'; @@ -653,6 +790,64 @@ test('definitive create failure leaves a retryable reservation', async (context) assert.equal(client.calls.filter((call) => call[0] === 'createAgent').length, 2); }); +test('known Cursor agent-id conflicts are definitive and preserve the provider code', async (context) => { + const { client, service } = await serviceFixture(context); + const originalCreate = client.createAgent.bind(client); + client.createAgent = async (body) => { + client.calls.push(['createAgent', body]); + if (client.calls.filter((call) => call[0] === 'createAgent').length === 1) { + throw new CursorApiError('conflict', 'Cursor rejected the requested agent ID.', { + status: 409, + providerCode: 'agent_id_conflict', + }); + } + return originalCreate(body); + }; + const args = { + action: 'create', requestId: 'agent-id-conflict-1', agentId, + prompt: { text: 'retry after definitive conflict' }, + }; + const first = await handleToolCall('agents', args, service); + assert.equal(first.structuredContent.error.code, 'conflict'); + const failed = await service.ledger.lookup(args.requestId); + assert.equal(failed.status, 'failed'); + assert.equal(failed.providerCode, 'agent_id_conflict'); + const retry = await handleToolCall('agents', args, service); + assert.equal(retry.structuredContent.ok, true); + assert.equal(retry.structuredContent.receipt.duplicate, false); +}); + +test('HTTP 429 rate limits are definitive and preserve the provider code', async (context) => { + const { client, service } = await serviceFixture(context); + const originalCreate = client.createAgent.bind(client); + let attempts = 0; + client.createAgent = async (body) => { + attempts += 1; + if (attempts === 1) { + throw new CursorApiError('rate_limited', 'Cursor rate limit reached.', { + status: 429, + retryable: true, + providerCode: 'rate_limit_exceeded', + }); + } + return originalCreate(body); + }; + const args = { + action: 'create', requestId: 'rate-limit-definitive-1', agentId, + prompt: { text: 'retry after definitive rate limit' }, + }; + const first = await handleToolCall('agents', args, service); + assert.equal(first.structuredContent.error.code, 'rate_limited'); + const failed = await service.ledger.lookup(args.requestId); + assert.equal(failed.status, 'failed'); + assert.equal(failed.providerCode, 'rate_limit_exceeded'); + + const retry = await handleToolCall('agents', args, service); + assert.equal(retry.structuredContent.ok, true); + assert.equal(retry.structuredContent.receipt.duplicate, false); + assert.equal(attempts, 2); +}); + test('retryable upstream mutation failures remain uncertain and are never resubmitted', async (context) => { const { client, service } = await serviceFixture(context); client.failCreateAgent = 'upstream_failure'; @@ -670,6 +865,166 @@ test('retryable upstream mutation failures remain uncertain and are never resubm assert.equal(client.calls.filter((call) => call[0] === 'createAgent').length, 1); }); +test('explicit provider-404 reconciliation releases an uncertain create reservation without duplicating it', async (context) => { + const { client, service } = await serviceFixture(context); + client.failCreateAgent = 'upstream_failure'; + const args = { + action: 'create', requestId: 'reconcile-create-1', agentId, + prompt: { text: 'submit once, then reconcile' }, + }; + + const first = await handleToolCall('agents', args, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + assert.equal((await service.ledger.lookup(args.requestId)).status, 'uncertain'); + + const sameRequest = await handleToolCall('agents', args, service); + assert.equal(sameRequest.structuredContent.error.code, 'uncertain_submission'); + + const newRequestSameAgent = await handleToolCall('agents', { + ...args, requestId: 'reconcile-create-2', + }, service); + assert.equal(newRequestSameAgent.structuredContent.error.code, 'uncertain_submission'); + assert.equal(client.calls.filter((call) => call[0] === 'createAgent').length, 1); + + client.notFoundAgent = true; + client.notFoundRuns = true; + const reconciled = await handleToolCall('agents', { + action: 'reconcile', requestId: args.requestId, + }, service); + assert.equal(reconciled.structuredContent.ok, true); + assert.deepEqual(reconciled.structuredContent.provider, { + agent: 'not_found', runs: 'not_found', reservation: 'released', + }); + assert.equal(reconciled.structuredContent.status, 'failed'); + assert.equal(reconciled.structuredContent.agentId, agentId); + assert.equal((await service.ledger.lookup(args.requestId)).reconciliationReason, 'provider_not_found'); + assert.deepEqual(client.calls.slice(-4), [ + ['getAgent', agentId], ['listRuns', agentId, {}], + ['getAgent', agentId], ['listRuns', agentId, {}], + ]); + + client.failCreateAgent = null; + client.notFoundAgent = false; + client.notFoundRuns = false; + const retry = await handleToolCall('agents', args, service); + assert.equal(retry.structuredContent.ok, true); + assert.equal(retry.structuredContent.receipt.duplicate, false); + const newRequestAfterReconcile = await handleToolCall('agents', { + ...args, requestId: 'reconcile-create-2', + }, service); + assert.equal(newRequestAfterReconcile.structuredContent.ok, true); + assert.equal(client.calls.filter((call) => call[0] === 'createAgent').length, 3); +}); + +test('one provider 404 is not enough to release an uncertain create reservation', async (context) => { + const { client, service } = await serviceFixture(context); + client.failCreateAgent = 'upstream_failure'; + const args = { + action: 'create', requestId: 'reconcile-create-3', agentId, + prompt: { text: 'confirm both provider paths' }, + }; + const first = await handleToolCall('agents', args, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + + client.notFoundAgent = true; + const incomplete = await handleToolCall('agents', { + action: 'reconcile', requestId: args.requestId, + }, service); + assert.equal(incomplete.structuredContent.error.code, 'uncertain_submission'); + assert.equal((await service.ledger.lookup(args.requestId)).status, 'uncertain'); + assert.deepEqual(client.calls.slice(-4), [ + ['getAgent', agentId], ['listRuns', agentId, {}], + ['getAgent', agentId], ['listRuns', agentId, {}], + ]); +}); + +test('reconciliation of a provider-visible agent finalizes completed without resubmitting', async (context) => { + const { client, service } = await serviceFixture(context); + client.failCreateAgent = 'upstream_failure'; + const args = { + action: 'create', requestId: 'reconcile-create-4', agentId, + prompt: { text: 'agent may already exist' }, + }; + const first = await handleToolCall('agents', args, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + client.failCreateAgent = null; + + const reconciled = await handleToolCall('agents', { + action: 'reconcile', requestId: args.requestId, + }, service); + assert.equal(reconciled.structuredContent.ok, true); + assert.equal(reconciled.structuredContent.provider.agent, 'found'); + assert.equal(reconciled.structuredContent.status, 'completed'); + assert.equal(reconciled.structuredContent.runId, runId); + assert.equal(client.calls.filter((call) => call[0] === 'createAgent').length, 1); + const duplicate = await handleToolCall('agents', args, service); + assert.equal(duplicate.structuredContent.ok, true); + assert.equal(duplicate.structuredContent.receipt.duplicate, true); + assert.equal(client.calls.filter((call) => call[0] === 'createAgent').length, 1); +}); + +test('agent reconciliation rejects a mismatched provider object without releasing', async (context) => { + const { client, service } = await serviceFixture(context); + client.failCreateAgent = 'upstream_failure'; + const args = { + action: 'create', requestId: 'reconcile-agent-identity-mismatch-1', agentId, + prompt: { text: 'reconcile one exact agent' }, + }; + const first = await handleToolCall('agents', args, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + client.getAgent = async (requestedAgentId) => { + client.calls.push(['getAgent', requestedAgentId]); + return { id: otherAgentId, latestRunId: runId }; + }; + const mismatch = await handleToolCall('agents', { action: 'reconcile', requestId: args.requestId }, service); + assert.equal(mismatch.structuredContent.error.code, 'uncertain_submission'); + const record = await service.ledger.lookup(args.requestId); + assert.equal(record.status, 'uncertain'); + assert.equal(mismatch.structuredContent.error.details.providerReturnedAgentId, otherAgentId); + assert.equal(client.calls.filter((call) => call[0] === 'createAgent').length, 1); +}); + +test('reconciliation retries a transient pair of 404s before releasing or completing', async (context) => { + const { client, service } = await serviceFixture(context); + client.failCreateAgent = 'upstream_failure'; + const args = { + action: 'create', requestId: 'reconcile-create-5', agentId, + prompt: { text: 'wait through eventual consistency' }, + }; + const first = await handleToolCall('agents', args, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + + let lookups = 0; + client.getAgent = async (id) => { + client.calls.push(['getAgent', id]); + lookups += 1; + if (lookups === 1) throw new CursorApiError('not_found', 'Cursor API returned HTTP 404.', { status: 404 }); + return { id, latestRunId: runId }; + }; + const reconciled = await handleToolCall('agents', { action: 'reconcile', requestId: args.requestId }, service); + assert.equal(reconciled.structuredContent.ok, true); + assert.equal(reconciled.structuredContent.provider.agent, 'found'); + assert.equal(lookups, 2); + assert.deepEqual(client.calls.slice(-3), [ + ['getAgent', agentId], ['listRuns', agentId, {}], ['getAgent', agentId], + ]); + assert.equal(client.calls.filter((call) => call[0] === 'createAgent').length, 1); +}); + +test('agent reconciliation rejects follow-up reservations instead of binding a caller target', async (context) => { + const { client, service } = await serviceFixture(context); + client.failFollowup = true; + const followup = await handleToolCall('runs', { + action: 'followup', requestId: 'reconcile-followup-1', agentId, prompt: { text: 'continue once' }, + }, service); + assert.equal(followup.structuredContent.error.code, 'uncertain_submission'); + const result = await handleToolCall('agents', { + action: 'reconcile', requestId: 'reconcile-followup-1', agentId, + }, service); + assert.equal(result.structuredContent.error.code, 'reconciliation_not_supported'); + assert.equal(client.calls.filter((call) => call[0] === 'getAgent').length, 0); +}); + test('a missing reservation after provider success fails uncertain and prevents resubmission', async (context) => { const { client, service } = await serviceFixture(context); const args = { action: 'create', requestId: 'missing-final-record-1', prompt: { text: 'create once' } }; @@ -687,7 +1042,7 @@ test('a missing reservation after provider success fails uncertain and prevents assert.equal(client.calls.filter((call) => call[0] === 'createAgent').length, 1); }); -test('changed create intent conflicts even when the first request generated its agent ID', async (context) => { +test('changed create intent conflicts after a provider-assigned create', async (context) => { const { client, service } = await serviceFixture(context); const first = await handleToolCall('agents', { action: 'create', requestId: 'changed-create-1', prompt: { text: 'original intent' }, @@ -714,6 +1069,177 @@ test('uncertain follow-up is ledgered and cannot be silently duplicated', async assert.equal(client.calls.filter((call) => call[0] === 'createRun').length, 1); }); +test('follow-up success without a provider run ID remains uncertain for every incomplete response shape', async (context) => { + const { client, service } = await serviceFixture(context); + const responses = [{}, { run: {} }, { run: { status: 'CREATING' } }]; + const agentTargets = [agentId, 'bc-00000000-0000-0000-0000-000000000002', 'bc-00000000-0000-0000-0000-000000000003']; + let responseIndex = 0; + client.createRun = async (agent, body) => { + client.calls.push(['createRun', agent, body]); + return responses[responseIndex++]; + }; + + for (let index = 0; index < responses.length; index += 1) { + const requestId = `followup-empty-response-${index + 1}`; + const result = await handleToolCall('runs', { + action: 'followup', requestId, agentId: agentTargets[index], prompt: { text: `missing run ${index}` }, + }, service); + assert.equal(result.structuredContent.error.code, 'uncertain_submission'); + assert.equal((await service.ledger.lookup(requestId)).status, 'uncertain'); + } + assert.equal(client.calls.filter((call) => call[0] === 'createRun').length, responses.length); +}); + +test('uncertain follow-up can be reconciled by an observed run or explicitly released without resubmission', async (context) => { + const { client, service } = await serviceFixture(context); + client.failFollowup = true; + const args = { action: 'followup', requestId: 'followup-reconcile-1', agentId, prompt: { text: 'continue once' } }; + const first = await handleToolCall('runs', args, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + const reconciled = await handleToolCall('runs', { action: 'reconcile', requestId: args.requestId, agentId, runId }, service); + assert.equal(reconciled.structuredContent.ok, true); + assert.equal(reconciled.structuredContent.provider.state, 'found'); + assert.equal((await service.ledger.lookup(args.requestId)).status, 'completed'); + assert.equal(client.calls.filter((call) => call[0] === 'createRun').length, 1); + + client.failFollowup = true; + const releaseArgs = { action: 'followup', requestId: 'followup-release-1', agentId, prompt: { text: 'release me' } }; + await handleToolCall('runs', releaseArgs, service); + const released = await handleToolCall('runs', { + action: 'reconcile', requestId: releaseArgs.requestId, release: true, confirmation: `release:${releaseArgs.requestId}`, + }, service); + assert.equal(released.structuredContent.ok, true); + assert.equal(released.structuredContent.provider.reservation, 'released'); + assert.equal(client.calls.filter((call) => call[0] === 'createRun').length, 2); +}); + +test('follow-up does not finalize a provider run returned for a different agent', async (context) => { + const { client, service } = await serviceFixture(context); + client.createRun = async (requestedAgentId, body) => { + client.calls.push(['createRun', requestedAgentId, body]); + return { run: { id: runId, agentId: otherAgentId, status: 'CREATING' } }; + }; + const args = { action: 'followup', requestId: 'followup-provider-identity-mismatch-1', agentId, prompt: { text: 'exact agent only' } }; + const result = await handleToolCall('runs', args, service); + assert.equal(result.structuredContent.error.code, 'uncertain_submission'); + assert.equal((await service.ledger.lookup(args.requestId)).status, 'uncertain'); + assert.equal(client.calls.filter((call) => call[0] === 'createRun').length, 1); +}); + +test('follow-up reconciliation rejects a mismatched run object without releasing', async (context) => { + const { client, service } = await serviceFixture(context); + client.failFollowup = true; + const args = { action: 'followup', requestId: 'followup-reconcile-identity-mismatch-1', agentId, prompt: { text: 'reconcile one exact run' } }; + const first = await handleToolCall('runs', args, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + client.getRun = async (requestedAgentId, requestedRunId) => { + client.calls.push(['getRun', requestedAgentId, requestedRunId]); + return { id: requestedRunId, agentId: otherAgentId, status: 'FINISHED' }; + }; + const mismatch = await handleToolCall('runs', { action: 'reconcile', requestId: args.requestId, agentId, runId }, service); + assert.equal(mismatch.structuredContent.error.code, 'uncertain_submission'); + const record = await service.ledger.lookup(args.requestId); + assert.equal(record.status, 'uncertain'); + assert.equal(mismatch.structuredContent.error.details.providerReturnedRunAgentId, otherAgentId); + assert.equal(client.calls.filter((call) => call[0] === 'createRun').length, 1); +}); + +test('follow-up reconciliation requires repeated exact 404s before releasing', async (context) => { + const { client, service } = await serviceFixture(context); + client.failFollowup = true; + const args = { action: 'followup', requestId: 'followup-reconcile-404-confirmation-1', agentId, prompt: { text: 'confirm one exact run absence' } }; + const first = await handleToolCall('runs', args, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + + let lookups = 0; + client.getRun = async (requestedAgentId, requestedRunId) => { + client.calls.push(['getRun', requestedAgentId, requestedRunId]); + lookups += 1; + throw new CursorApiError('not_found', 'missing run', { status: 404 }); + }; + const oneMiss = await handleToolCall('runs', { + action: 'reconcile', requestId: args.requestId, agentId, runId, + }, service); + assert.equal(oneMiss.structuredContent.error.code, 'uncertain_submission'); + assert.equal((await service.ledger.lookup(args.requestId)).status, 'uncertain'); + assert.equal((await service.ledger.lookup(args.requestId)).providerNotFoundConfirmations, 1); + assert.equal(client.calls.filter((call) => call[0] === 'createRun').length, 1); + + const confirmed = await handleToolCall('runs', { + action: 'reconcile', requestId: args.requestId, agentId, runId, + }, service); + assert.equal(confirmed.structuredContent.ok, true); + assert.equal(confirmed.structuredContent.provider.reservation, 'released'); + assert.equal((await service.ledger.lookup(args.requestId)).status, 'failed'); + assert.equal(lookups, 2); + assert.equal(client.calls.filter((call) => call[0] === 'createRun').length, 1); +}); + +test('uncertain cancellation requires a terminal cancelled provider run and safely releases on exact 404', async (context) => { + const { client, service } = await serviceFixture(context); + const cancelRequest = { action: 'cancel', requestId: 'cancel-reconcile-status-1', agentId, runId }; + client.cancelRun = async (id, run) => { + client.calls.push(['cancelRun', id, run]); + throw new CursorApiError('network_error', 'response lost', { ambiguous: true }); + }; + const first = await handleToolCall('runs', cancelRequest, service); + assert.equal(first.structuredContent.error.code, 'uncertain_submission'); + + client.getRun = async (id, run) => { + client.calls.push(['getRun', id, run]); + return { id: run, agentId: otherAgentId, status: 'CANCELED' }; + }; + const mismatched = await handleToolCall('runs', { action: 'reconcile', requestId: cancelRequest.requestId, agentId, runId }, service); + assert.equal(mismatched.structuredContent.error.code, 'uncertain_submission'); + assert.equal((await service.ledger.lookup(cancelRequest.requestId)).status, 'uncertain'); + + client.getRun = async (id, run) => { + client.calls.push(['getRun', id, run]); + return { id: run, agentId: id, status: 'RUNNING' }; + }; + const stillRunning = await handleToolCall('runs', { action: 'reconcile', requestId: cancelRequest.requestId, agentId, runId }, service); + assert.equal(stillRunning.structuredContent.error.code, 'uncertain_submission'); + assert.equal((await service.ledger.lookup(cancelRequest.requestId)).status, 'uncertain'); + + client.getRun = async (id, run) => { + client.calls.push(['getRun', id, run]); + return { id: run, agentId: id, status: 'CANCELED' }; + }; + const canceled = await handleToolCall('runs', { action: 'reconcile', requestId: cancelRequest.requestId, agentId, runId }, service); + assert.equal(canceled.structuredContent.ok, true); + assert.equal(canceled.structuredContent.provider.reservation, 'completed'); + assert.equal((await service.ledger.lookup(cancelRequest.requestId)).status, 'completed'); + assert.equal(client.calls.filter((call) => call[0] === 'cancelRun').length, 1); + + const missingRequest = { action: 'cancel', requestId: 'cancel-reconcile-404-1', agentId, runId: 'run-00000000-0000-0000-0000-000000000002' }; + const secondCancel = await handleToolCall('runs', missingRequest, service); + assert.equal(secondCancel.structuredContent.error.code, 'uncertain_submission'); + client.getRun = async (id, run) => { + client.calls.push(['getRun', id, run]); + throw new CursorApiError('not_found', 'missing run', { status: 404 }); + }; + const oneMiss = await handleToolCall('runs', { action: 'reconcile', requestId: missingRequest.requestId, agentId, runId: missingRequest.runId }, service); + assert.equal(oneMiss.structuredContent.error.code, 'uncertain_submission'); + assert.equal((await service.ledger.lookup(missingRequest.requestId)).status, 'uncertain'); + const released = await handleToolCall('runs', { action: 'reconcile', requestId: missingRequest.requestId, agentId, runId: missingRequest.runId }, service); + assert.equal(released.structuredContent.ok, true); + assert.equal(released.structuredContent.provider.reservation, 'released'); + assert.equal((await service.ledger.lookup(missingRequest.requestId)).providerAgentId, agentId); +}); + +test('cancel does not finalize a mismatched provider acknowledgement', async (context) => { + const { client, service } = await serviceFixture(context); + client.cancelRun = async (requestedAgentId, requestedRunId) => { + client.calls.push(['cancelRun', requestedAgentId, requestedRunId]); + return { id: otherRunId, agentId: otherAgentId }; + }; + const args = { action: 'cancel', requestId: 'cancel-provider-identity-mismatch-1', agentId, runId }; + const result = await handleToolCall('runs', args, service); + assert.equal(result.structuredContent.error.code, 'uncertain_submission'); + assert.equal((await service.ledger.lookup(args.requestId)).status, 'uncertain'); + assert.equal(client.calls.filter((call) => call[0] === 'cancelRun').length, 1); +}); + test('list, usage, cancellation, and deletion use exact typed endpoint operations', async (context) => { const { client, service } = await serviceFixture(context); const listed = await handleToolCall('agents', { action: 'list', limit: 1, includeArchived: false }, service); @@ -727,6 +1253,42 @@ test('list, usage, cancellation, and deletion use exact typed endpoint operation assert.ok(client.calls.some((call) => call[0] === 'usage' && call[2] === undefined)); }); +test('runs.wait forwards each remaining deadline to provider reads', async (context) => { + const { client, service } = await serviceFixture(context); + const timeouts = []; + let reads = 0; + client.getRun = async (id, run, options) => { + client.calls.push(['getRun', id, run, options]); + timeouts.push(options?.timeoutMs); + reads += 1; + return reads > 1 ? { id: run, status: 'FINISHED' } : { id: run, status: 'CREATING' }; + }; + const result = await handleToolCall('runs', { action: 'wait', agentId, runId, timeoutMs: 600, pollMs: 250 }, service); + assert.equal(result.structuredContent.ok, true); + assert.equal(result.structuredContent.timedOut, false); + assert.equal(timeouts.length, 2); + assert.ok(timeouts[0] <= 600 && timeouts[0] > 0); + assert.ok(timeouts[1] <= timeouts[0] && timeouts[1] > 0); +}); + +test('runs.wait converts provider request timeouts into bounded timedOut receipts with the latest run', async (context) => { + const { client, service } = await serviceFixture(context); + let reads = 0; + client.getRun = async (id, run, options) => { + client.calls.push(['getRun', id, run, options]); + reads += 1; + if (reads === 1) return { id: run, status: 'CREATING', progress: 'partial' }; + throw new CursorApiError('request_timeout', 'provider read exceeded its remaining bound', { + details: { partial: { id: run, status: 'CREATING', progress: 'latest' } }, + }); + }; + const result = await handleToolCall('runs', { action: 'wait', agentId, runId, timeoutMs: 600, pollMs: 250 }, service); + assert.equal(result.structuredContent.ok, true); + assert.equal(result.structuredContent.timedOut, true); + assert.deepEqual(result.structuredContent.run, { id: runId, status: 'CREATING', progress: 'latest' }); + assert.equal(reads, 2); +}); + test('expired streams reconcile the exact run without resubmitting', async (context) => { const { client, service } = await serviceFixture(context); client.streamRun = async (agent, run, options) => { diff --git a/plugins/cursor-cloud-control/test/validation.test.mjs b/plugins/cursor-cloud-control/test/validation.test.mjs index b36f568..4bfe6a6 100644 --- a/plugins/cursor-cloud-control/test/validation.test.mjs +++ b/plugins/cursor-cloud-control/test/validation.test.mjs @@ -28,6 +28,37 @@ test('create requires a stable request ID, usage permits an omitted run ID, and assert.throws(() => validateToolInput('status', { action: 'identity', fullIdentity: true }), /not supported/); }); +test('agent reconciliation requires only a stable request ID and accepts an optional opaque provider ID', () => { + assert.deepEqual(validateToolInput('agents', { + action: 'reconcile', requestId: 'reconcile-validation-1', + }), { + action: 'reconcile', requestId: 'reconcile-validation-1', + }); + assert.equal(validateToolInput('agents', { + action: 'reconcile', requestId: 'reconcile-validation-2', agentId, + }).agentId, agentId); + assert.throws(() => validateToolInput('agents', { + action: 'reconcile', requestId: 'reconcile-validation-3', prompt: { text: 'not accepted' }, + }), /not supported/); +}); + +test('run and lifecycle reconciliation expose typed release confirmations and cancellation request IDs', () => { + assert.deepEqual(validateToolInput('runs', { action: 'cancel', requestId: 'cancel-validation-1', agentId, runId }), { + action: 'cancel', requestId: 'cancel-validation-1', agentId, runId, + }); + assert.deepEqual(validateToolInput('runs', { + action: 'reconcile', requestId: 'run-reconcile-validation-1', release: true, confirmation: 'release:run-reconcile-validation-1', + }), { + action: 'reconcile', requestId: 'run-reconcile-validation-1', release: true, confirmation: 'release:run-reconcile-validation-1', + }); + assert.deepEqual(validateToolInput('lifecycle', { + action: 'reconcile', requestId: 'life-reconcile-validation-1', agentId, + }), { action: 'reconcile', requestId: 'life-reconcile-validation-1', agentId }); + assert.throws(() => validateToolInput('lifecycle', { + action: 'reconcile', requestId: 'life-reconcile-validation-2', release: true, + }), /confirmation is required/); +}); + test('write-mode repository dispatch requires an immutable start commit', () => { assert.throws(() => validateToolInput('agents', { action: 'create', requestId: 'create-agent-1', mode: 'agent', prompt: { text: 'implement' }, diff --git a/plugins/plumbob-harness-control/.codex-plugin/plugin.json b/plugins/plumbob-harness-control/.codex-plugin/plugin.json index 8bb6497..18a674c 100644 --- a/plugins/plumbob-harness-control/.codex-plugin/plugin.json +++ b/plugins/plumbob-harness-control/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "plumbob-harness-control", - "version": "2.1.2", + "version": "2.2.0", "description": "Codex-Co-Engineer gives Codex a bounded control plane for DeepSeek Harness and Grok Build jobs.", "author": { "name": "Plumbob" diff --git a/plugins/plumbob-harness-control/LICENSE b/plugins/plumbob-harness-control/LICENSE new file mode 100644 index 0000000..161b80b --- /dev/null +++ b/plugins/plumbob-harness-control/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Plumbob + +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/plumbob-harness-control/README.md b/plugins/plumbob-harness-control/README.md index ed5e8f0..271d72b 100644 --- a/plugins/plumbob-harness-control/README.md +++ b/plugins/plumbob-harness-control/README.md @@ -8,21 +8,27 @@ inspect or cancel plugin-owned jobs without opening a shell. Version 2 removes all Prime Intellect adapters and runtime dependencies; the only worker kinds are `deepseek_agent` and `grok_build`. +This README documents Co-Engineer `2.2.0`; the stable plugin and MCP +identifier remains `plumbob-harness-control`. + The public product name is **Codex-Co-Engineer**. The stable plugin and MCP identifier remains `plumbob-harness-control` so existing Codex configurations and automation continue to resolve the same server. ## What it provides -The MCP server exposes seven compact tools: +The MCP server exposes seven compact tools plus an explicit control-plane +target-binding convenience path: -- `preflight`: resolve and attest one strict target/configuration -- `status`: provider-free control-plane, adapter, credential-presence, and recent-job state -- `capacity`: explicit read-only Codex, Grok, and DSH capacity/usage snapshot -- `runtime`: start or stop the optional loopback DeepSeek UI -- `run`: accept a target-bound `deepseek_agent` or `grok_build` job -- `jobs`: list, inspect, wait for, or cursor-page a managed job -- `cancel`: request cancellation of one plugin-owned job +- Co-Engineer MCP `preflight`: resolve and attest one strict target/configuration +- Co-Engineer MCP `status`: provider-free control-plane, adapter, credential-presence, and recent-job state +- Co-Engineer MCP `capacity`: explicit read-only Codex, Grok, and DSH capacity/usage snapshot +- Co-Engineer MCP `runtime`: start or stop the optional loopback DeepSeek UI +- Co-Engineer MCP `run`: accept a target-bound `deepseek_agent` or `grok_build` job +- Co-Engineer MCP `jobs`: list, inspect, wait for, or cursor-page a managed job; terminal Grok + jobs also expose a bounded final response without requiring callers to parse + the full provider log +- Co-Engineer MCP `cancel`: request cancellation of one plugin-owned job The control plane stores only bounded metadata and redacted logs in an owner-only state directory. It does not accept arbitrary shell commands, @@ -53,8 +59,15 @@ tools and not Codex-native subagents. Codex-Co-Engineer never installs the CLI, opens a browser, or accepts credentials as tool arguments. 3. Install and configure DeepSeek Harness separately when using DeepSeek jobs. -4. Clone this repository and register - `plugins/plumbob-harness-control` as a local Codex plugin. + A `deepseek_agent` run also requires `MODEL_API_KEY` in the MCP process + environment or an owner-only model-key file. The default file is under + `XDG_CONFIG_HOME` and can be overridden with + `CODEX_CO_ENGINEER_MODEL_API_KEY_FILE`. If neither source is available, + dispatch fails closed with `missing_credential` before a DSH worker is + submitted. +4. Add the repository root as a Codex marketplace and install the + `plumbob-harness-control` entry. The public root catalog and the complete + command sequence are in the repository [README](../../README.md). 5. Set the runtime environment described below before Codex starts the MCP server. If the task sandbox makes the normal home directory read-only, provide the host-provisioned `CODEX_TASK_STATE_ROOT` (Co-Engineer uses its @@ -67,6 +80,53 @@ The plugin has no runtime npm dependencies. DeepSeek Harness and Grok Build are installed and authenticated independently. The public repository deliberately does not include generated Harness profiles, session logs, or credentials. +### Register the plugin in Codex + +The current Codex CLI uses a marketplace index for plugin registration. The +public repository ships that index at `.agents/plugins/marketplace.json`; add +the repository root as a marketplace and select this plugin: + +```bash +codex plugin marketplace add ajhcs/Codex-Co-Engineer --ref main +codex plugin marketplace list --json +codex plugin list --available --json +codex plugin add plumbob-harness-control@codex-co-engineer +codex plugin list --json +``` + +`codex plugin add ./plugins/plumbob-harness-control` is not a supported +command. The Codex App reads the enabled plugin configuration after +activation. Fully restart the App and start a fresh task after installing or +changing a plugin: `codex plugin list --json` verifies installation/enabled +state, while an existing task can retain a stale MCP or skill catalog. + +### First status, preflight, and run + +In the fresh Codex task, use the plugin tools in this order: + +1. Co-Engineer MCP `status` with `{}`. This is provider-free by default. Add + `{"diagnostics":true}` only when a bounded read-only Grok auth probe is + needed. +2. Co-Engineer MCP `preflight` with `schema_version: "codex-co-engineer.config.v1"`, + `kind: "preflight"`, `target_binding: "control_plane"`, and one exact + `target_context`. A local target uses `mode: "explicit"`, absolute + `working_directory` and `expected_git_root`, its current 40-character + `expected_head`, `allowed_paths`, and `role: "review"` or `"verify"`. + A GitHub target can use `mode: "staged"` and + `source: {"type":"github","repository":"https://github.com/OWNER/REPOSITORY","ref":"main"}`. +3. Co-Engineer MCP `run` with the same target context, a stable `request_id`, text-only + `prompt`, and exactly one worker kind: `deepseek_agent` or `grok_build`. + `target_binding: "control_plane"` lets the connector compute the target + fingerprint; it does not weaken path, HEAD, identity, or postflight checks. +4. Monitor the returned job with the Co-Engineer MCP `jobs` tool using + `{"action":"wait","job_id":"","until":"terminal"}`, then inspect it + with `{"action":"get","job_id":""}`. + +For a manual, provider-free end-to-end check from the repository root, run +`node scripts/inspector-preflight.mjs`; the exact custom-target form is in +[`docs/preflight-inspector.md`](../../docs/preflight-inspector.md). The public +release history is in [`CHANGELOG.md`](../../CHANGELOG.md). + ## Configuration The MCP server receives configuration through its process environment. A @@ -74,7 +134,7 @@ portable example is in [`config/configuration.example.json`](../../config/config | Variable | Purpose | | --- | --- | -| `MODEL_API_KEY` | Provider credential, supplied by the environment or a secret manager. | +| `MODEL_API_KEY` | DSH provider credential for `deepseek_agent` runs, supplied by the environment or a secret manager. | | `XAI_API_KEY` | Optional xAI API key for the official Grok CLI; OAuth/session state remains under the normal user home. Never pass it as an MCP argument. | | `DSH_HOME` | Optional absolute DeepSeek Harness profile/state home. When omitted, Co-Engineer uses its managed `dsh-home` beneath the configured state directory and never falls back to the protected per-user DSH home. | | `CODEX_CO_ENGINEER_DSH_HOME` | Preferred explicit absolute DeepSeek Harness profile/state home. Relative paths fail closed. | @@ -84,7 +144,7 @@ portable example is in [`config/configuration.example.json`](../../config/config | `PLUMBOB_HARNESS_STATE_DIR` | Legacy explicit alias for `CODEX_CO_ENGINEER_STATE_DIR`; it is used only when the preferred variable is absent. | | `CODEX_TASK_STATE_ROOT` | Host-provisioned shared durable root. When component-specific settings are absent, Co-Engineer uses `${CODEX_TASK_STATE_ROOT}/codex-co-engineer`; an empty or relative value fails closed instead of falling back. | | `XDG_STATE_HOME` | Absolute fallback state root; Co-Engineer uses `${XDG_STATE_HOME}/codex-co-engineer` when no explicit or host-shared root is configured. A present empty or relative value fails closed instead of falling through to HOME. | -| `CODEX_CO_ENGINEER_MODEL_API_KEY_FILE` | Optional protected file containing only the provider key; keep it outside the clone. | +| `CODEX_CO_ENGINEER_MODEL_API_KEY_FILE` | Optional protected file fallback containing only the DSH provider key; keep it outside the clone. | | `CODEX_CO_ENGINEER_DSH_COMMAND` | Optional DeepSeek Harness executable override; defaults to `dsh`; passed to `spawn` without a shell. | | `CODEX_CO_ENGINEER_GROK_COMMAND` | Optional direct Grok executable override; defaults to `grok`; passed to `spawn` without a shell. | @@ -106,17 +166,21 @@ interval; pre-existing symlinks and other unsafe objects are rejected. The seven MCP tools are intentionally narrow; removing Prime narrows only the accepted worker kinds and backend-specific fields. -- `preflight` is read-only. Supply `schema_version`, `target_context`, and the - caller-computed `expected_target_fingerprint`. Set `kind` to `preflight`, - `deepseek_agent`, or `grok_build`. A Grok preflight may include the same typed - Grok options accepted by `run`. -- `status` accepts optional `recent_limit` (`0`–`15`) and `diagnostics`. Recent - jobs and `jobs` action `list` return bounded summaries only; use `jobs` - action `get` for one job's effective configuration and lifecycle history. +- Co-Engineer MCP `preflight` is read-only. Existing callers may supply the caller-computed + `expected_target_fingerprint`. For normal Codex use, set + `target_binding: "control_plane"` and the connector computes and binds the + exact target identity itself; supplying a fingerprint alongside that opt-in + is still checked. Set `kind` to `preflight`, `deepseek_agent`, or + `grok_build`. A Grok preflight may include the same typed Grok options + accepted by the Co-Engineer MCP `run` tool. +- Co-Engineer MCP `status` accepts optional `recent_limit` (`0`–`15`) and + `diagnostics`. Recent jobs and Co-Engineer MCP `jobs` action `list` return + bounded summaries only; use that `jobs` tool's action `get` for one job's + effective configuration and lifecycle history. The normal path is provider-free. `diagnostics: true` is the existing explicit, bounded read-only `grok models` authentication probe; it is not a capacity query and is never started automatically. -- `capacity` is the one explicit provider-read surface. It is read-only and +- Co-Engineer MCP `capacity` is the one explicit provider-read surface. It is read-only and accepts `providers` (`codex`, `grok`, or `dsh`), `refresh`, bounded `max_age_seconds`, `include_usage`, `grok_session_id`, and `dsh_job_id`. Codex reads official App Server rate-limit/credit data (and optional @@ -130,15 +194,20 @@ accepted worker kinds and backend-specific fields. rather than fabricating zeros. Provider credentials come from the existing configured sessions/environment; capacity never accepts credentials or requests a per-call egress/authorization prompt. -- `runtime` accepts `action: "start"` with the versioned target contract and a +- Co-Engineer MCP `runtime` accepts `action: "start"` with the versioned target contract and a bounded timeout, or `action: "stop"` to stop only the plugin-owned DeepSeek UI job. -- `run` requires `schema_version`, `kind`, `request_id`, `prompt`, - `target_context`, and `expected_target_fingerprint`. `kind` is exactly - `deepseek_agent` or `grok_build`; unknown and removed kinds fail closed. -- `jobs` accepts `action: "list"`, `"get"`, `"wait"`, or `"logs"`. Waits are - bounded to 55 seconds per call and log reads use byte cursors. -- `cancel` requires one exact `job_id` and signals only a process whose +- Co-Engineer MCP `run` requires `schema_version`, `kind`, `request_id`, `prompt`, and + `target_context`. Existing callers may continue supplying + `expected_target_fingerprint`; normal Codex callers can explicitly set + `target_binding: "control_plane"` to bind the resolved target without + computing inode values by hand. `kind` is exactly `deepseek_agent` or + `grok_build`; unknown and removed kinds fail closed. +- Co-Engineer MCP `jobs` accepts `action: "list"`, `"get"`, `"wait"`, or `"logs"`. Waits are + bounded to 55 seconds per call and log reads use byte cursors. A terminal + Grok `get` includes only a bounded final assistant response; reasoning and + tool events are not promoted into that field. +- Co-Engineer MCP `cancel` requires one exact `job_id` and signals only a process whose ownership the plugin can prove. Minimal DeepSeek dispatch after a successful preflight: @@ -149,6 +218,7 @@ Minimal DeepSeek dispatch after a successful preflight: "kind": "deepseek_agent", "request_id": "review-example-001", "prompt": "Review the requested files and report findings.", + "target_binding": "control_plane", "target_context": { "schema_version": "codex-co-engineer.target.v1", "mode": "explicit", @@ -157,17 +227,70 @@ Minimal DeepSeek dispatch after a successful preflight: "expected_head": "0123456789abcdef0123456789abcdef01234567", "allowed_paths": ["src", "tests"], "role": "review" - }, - "expected_target_fingerprint": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + } } ``` +This explicit `target_binding: "control_plane"` path is the normal ergonomic +form: the connector resolves the target and binds its exact fingerprint. For +advanced callers that hold target authority themselves, omit `target_binding` +and provide the `expected_target_fingerprint` returned by +`scripts/target-fingerprint.mjs`; the connector still resolves and checks the +same paths, Git HEAD, and directory identities. + For Grok, change `kind` to `grok_build` and optionally add typed fields such as `model`, `reasoning_effort`, `max_turns`, `sandbox_profile`, `allowed_tools`, or the structured-output controls described below. Credentials, executable paths, raw arguments, shell commands, environment maps, and provider URLs are never accepted in a tool call. +For a normal read-only review of a clean local checkout or public/private GitHub +repository, use the explicit control-plane binding convenience form. A staged +source is cloned into the owner-only Co-Engineer state directory, its exact +HEAD and directory identities are resolved there, the origin remote is removed +before the provider starts, and the resulting target remains subject to the +same runner preflight/postflight checks. The original checkout is never +modified. Local sources with uncommitted or untracked files fail closed rather +than silently omitting those changes. Staging uses a deterministic source/ref/ +HEAD lease, so repeated preflight and run calls reuse one checkout; unused +leases expire after 24 hours and the control plane keeps at most eight +inactive leases. + +Private staged GitHub sources require credentials that Git can use +noninteractively from the MCP server process. Configure an owner-approved +credential helper, askpass/secret-manager integration, or equivalent process +environment before calling the Co-Engineer MCP `preflight` tool; staging +forces `GIT_TERMINAL_PROMPT=0`. The source URL must remain credential-free, and +credentials must never appear in `target_context`, prompts, or tool +arguments. A clone or ref lookup that cannot authenticate fails closed before +dispatch. + +```json +{ + "schema_version": "codex-co-engineer.config.v1", + "kind": "grok_build", + "request_id": "review-github-example-001", + "target_binding": "control_plane", + "prompt": "Review this repository and return only actionable findings.", + "target_context": { + "schema_version": "codex-co-engineer.target.v1", + "mode": "staged", + "source": { + "type": "github", + "repository": "https://github.com/OWNER/REPOSITORY", + "ref": "main" + }, + "allowed_paths": ["."], + "role": "review" + } +} +``` + +Use `mode: "explicit"` with `target_binding: "control_plane"` when a clean +local checkout is already in a suitable non-temporary directory; this avoids a +second clone while retaining exact target identity and runner checks. Staging +is opt-in and never replaces the caller-asserted contract silently. + Prefer a native per-user secret store. If a file is necessary, place it under the platform's user configuration directory, restrict it to the current user, and never put it under this repository or a Windows-mounted shared directory. @@ -181,7 +304,8 @@ worker starts. The target contract contains: - expected Git `HEAD` - relative `allowed_paths` - `role`: `review`, `verify`, or `implement` -- the caller's expected target fingerprint +- a target fingerprint, either caller-asserted or computed by the explicit + control-plane binding The connector canonicalizes the resolved target and configuration, computes digests, and fails closed on a mismatch. An explicit malformed target is an @@ -268,7 +392,7 @@ still verifies the configured executable with `grok --version` and records actual process-start failures from the managed spawn. The official CLI also provides ACP through `grok agent stdio`. The connector -uses ACP only for the `capacity` tool's read-only billing and exact session +uses ACP only for the Co-Engineer MCP `capacity` tool's read-only billing and exact session usage calls. Coding dispatch stays on the documented direct headless prompt interface; it is not routed through ACP and does not invent an ACP JSON-RPC proxy. Prompt-file/prompt-JSON input, system-prompt overrides, debug files, @@ -293,6 +417,35 @@ not accept `XAI_API_KEY`; its credential design is an attested owner-only Grok authentication file. Do not treat the presence of these packaged modules as runtime readiness. +## Troubleshooting + +- `missing_credential`: provide `MODEL_API_KEY` to the MCP process or set + `CODEX_CO_ENGINEER_MODEL_API_KEY_FILE` to an owner-only file outside the + checkout, then restart Codex so the MCP launcher inherits the change. Never + put credentials in a tool call or commit them. +- Grok reports `unauthenticated`, `Not signed in`, or `grok models` fails: + authenticate with the normal `grok login`/device flow, or provide + `XAI_API_KEY` in the MCP process environment. Re-run `grok models` and + restart Codex if the environment or home/session location changed. Provider + sessions are reused but can expire or be revoked; the plugin cannot refresh + them silently. +- `target_fingerprint_mismatch`: the checkout, Git HEAD, or directory identity + changed after the target was prepared. Re-read the exact HEAD, use a clean + checkout, and run the Co-Engineer MCP `preflight` tool again. Do not reuse a timed-out or cancelled + checkout until its changes and taint have been inspected. +- `unconfigured_home`, `state_directory_unwritable`, or `EROFS`: set an + absolute, owner-writable `CODEX_CO_ENGINEER_STATE_DIR`, or provide the + host-provisioned absolute `CODEX_TASK_STATE_ROOT`; ensure the MCP process + receives it and restart Codex. The plugin fails closed instead of falling + back to a protected or ambiguous home directory. +- The plugin is installed but its tools or skills are missing: run + `codex plugin list --json` and confirm `installed: true` and `enabled: true`, + then fully restart the Codex App and start a fresh task. There is no + `codex plugin reload` command; an existing task can retain a stale catalog. +- DSH status reports `unsupported_version`: the accepted adapter is + DeepSeek Harness `0.1.0-rc.6`; verify `dsh --version` and configure the + selected profile before retrying. + ## Development ```bash diff --git a/plugins/plumbob-harness-control/mcp/control.mjs b/plugins/plumbob-harness-control/mcp/control.mjs index c531a8e..85b1888 100644 --- a/plugins/plumbob-harness-control/mcp/control.mjs +++ b/plugins/plumbob-harness-control/mcp/control.mjs @@ -4,6 +4,12 @@ import { spawn, spawnSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { access, + chmod, + lstat, + mkdir, + mkdtemp, + readdir, + rm, open, readFile, rename, @@ -19,6 +25,7 @@ import { findStoredRequest, getStoredJob, insertJob, + listActiveStoredJobs, listLifecycleEvents, listStoredJobs, openStore, @@ -36,6 +43,7 @@ import { } from './preflight.mjs'; import { buildGrokArgs, + grokBuildFinalResponse, grokCapabilityProfile, grokVersionProbe, normalizeGrokConfiguration, @@ -52,11 +60,15 @@ import { resolveDshHome, } from './dsh.mjs'; import { CapacityError, createCapacityReader } from './capacity.mjs'; +import { readGrokCapacity } from './grok-capacity.mjs'; import { createDshReceiptReader } from './dsh-receipt.mjs'; import { + createExclusiveStateFile, inspectStateFile, + openStateFileRead, prepareStateFile, prepareStateDirectory, + removeStateFile, resolveStateDirectory, revalidateStateDirectory, sameStateIdentity, @@ -96,6 +108,8 @@ function grokEnvironment() { const RUNNER = path.join(PLUGIN_ROOT, 'mcp', 'runner.mjs'); const WEB_HOST = '127.0.0.1'; const WEB_PORT = 3180; +const DSH_WEB_LOCK_FILE = 'dsh-web-runtime.lock'; +const DSH_WEB_LOCK_SCHEMA = 'codex-co-engineer.dsh-web-lock.v1'; const FINAL_STATES = new Set([ 'completed', 'succeeded', @@ -119,10 +133,11 @@ const WAIT_LIMITS = Object.freeze({ const LOG_PAGE_MAX_BYTES = WAIT_LIMITS.log_page_bytes.maximum; const COMPACT_JOB_TEXT_MAX_LENGTH = 160; const TARGET_ROLES = new Set(['review', 'implement', 'verify']); -const TARGET_MODES = new Set(['default', 'explicit']); +const TARGET_MODES = new Set(['default', 'explicit', 'staged']); const TARGET_CONTEXT_KEYS = new Set([ 'schema_version', 'mode', + 'source', 'working_directory', 'expected_git_root', 'expected_head', @@ -130,11 +145,24 @@ const TARGET_CONTEXT_KEYS = new Set([ 'role', ]); +const TARGET_SOURCE_TYPES = new Set(['local', 'github']); +const TARGET_SOURCE_KEYS = new Set(['type', 'path', 'repository', 'ref']); +const TARGET_STAGE_ROOT_NAME = 'targets'; +const TARGET_STAGE_LEASE_TTL_MS = 24 * 60 * 60 * 1000; +const TARGET_STAGE_MAX_LEASES = 8; +const TARGET_STAGE_LOCK_STALE_MS = 2 * 60 * 1000; +const TARGET_STAGE_ACQUIRE_TIMEOUT_MS = 60 * 1000; +const TARGET_STAGE_RECONCILE_LIMIT = 256; +const TARGET_STAGE_GIT_TIMEOUT_MS = 15 * 1000; +const TARGET_STAGE_DEADLINE_MS = 45 * 1000; +const TARGET_STAGE_MAX_BYTES = 256 * 1024 * 1024; +const TARGET_STAGE_MAX_ENTRIES = 100_000; + // Grok's built-in `read-only` profile explicitly permits writes to these // locations. The runner can detect a changed checkout after the fact, but -// that is not a prevention boundary. Refuse review/verify targets rooted in -// a provider-writable directory until the connector can provision and verify -// a target-specific custom profile (whose startup failure is fail-closed). +// that is not a prevention boundary. Refuse review/verify targets rooted in a +// provider-writable directory unless the connector created an owner-only, +// isolated staged checkout beneath its identity-bound state directory. const GROK_READ_ONLY_WRITABLE_ROOTS = Object.freeze([...new Set([ '/tmp', '/var/tmp', @@ -161,6 +189,7 @@ let databaseStateIdentity; let databaseJobsIdentity; let databaseFileIdentity; let statePreparationTail = Promise.resolve(); +let controlProcessStartTime; const SQLITE_STATE_CHILDREN = Object.freeze([ 'control.sqlite3-wal', @@ -249,6 +278,165 @@ function ensureState() { return result; } +function processStartTimeFromStat(statText) { + const closingParenthesis = statText.lastIndexOf(')'); + if (closingParenthesis < 0) return null; + return statText.slice(closingParenthesis + 2).trim().split(/\s+/u)[19] ?? null; +} + +async function currentControlProcessStartTime() { + if (controlProcessStartTime !== undefined) return controlProcessStartTime; + try { + controlProcessStartTime = processStartTimeFromStat( + await readFile(`/proc/${process.pid}/stat`, 'utf8'), + ); + } catch { + controlProcessStartTime = null; + } + return controlProcessStartTime; +} + +async function processIdentityAlive(record) { + if (!Number.isInteger(record?.pid) || record.pid < 2) return false; + try { + process.kill(record.pid, 0); + if (typeof record.start_time !== 'string' || !record.start_time) return true; + const observed = processStartTimeFromStat( + await readFile(`/proc/${record.pid}/stat`, 'utf8'), + ); + return observed !== null && observed === record.start_time; + } catch { + return false; + } +} + +function runtimeLockError(message) { + return new ToolError('runtime_lock_unverifiable', message); +} + +async function readWebRuntimeLock() { + await ensureState(); + const name = DSH_WEB_LOCK_FILE; + const identity = await inspectStateFile(stateHandle, name, { required: false }); + if (!identity) return null; + let record; + try { + const opened = await openStateFileRead(stateHandle, name, { expectedIdentity: identity }); + try { + record = JSON.parse(await opened.file.readFile('utf8')); + } finally { + await opened.file.close(); + } + await inspectStateFile(stateHandle, name, { expectedIdentity: identity }); + } catch { + throw runtimeLockError('The DSH web runtime lock could not be identity-verified; refusing to start another listener.'); + } + if (record?.schema_version !== DSH_WEB_LOCK_SCHEMA + || !['starting', 'active'].includes(record.state) + || !Number.isInteger(record.pid) + || record.pid < 2 + || typeof record.start_time !== 'string' + || !record.start_time + || (record.state === 'active' + && (typeof record.job_id !== 'string' || !/^[a-z0-9-]{8,96}$/u.test(record.job_id)))) { + throw runtimeLockError('The DSH web runtime lock has an invalid ownership record; refusing replacement.'); + } + return { identity, record }; +} + +async function releaseWebRuntimeLock(lock) { + if (!lock) return; + await lock.file?.close().catch(() => {}); + try { + await removeStateFile( + stateHandle, + DSH_WEB_LOCK_FILE, + { expectedIdentity: lock.identity }, + ); + } catch (error) { + // A different owner may have reclaimed the exact lock after this holder + // lost its path. Never remove or overwrite that replacement. + if (error?.code !== 'state_identity_changed' && error?.code !== 'state_child_missing') throw error; + } +} + +async function writeWebRuntimeLock(lock, record) { + const payload = `${JSON.stringify(record)}\n`; + await lock.file.truncate(0); + await lock.file.write(payload, 0, 'utf8'); + await lock.file.sync(); + await inspectStateFile(stateHandle, DSH_WEB_LOCK_FILE, { expectedIdentity: lock.identity }); + lock.record = record; +} + +async function acquireWebRuntimeLock() { + await ensureState(); + const owner = { + schema_version: DSH_WEB_LOCK_SCHEMA, + state: 'starting', + pid: process.pid, + start_time: await currentControlProcessStartTime(), + created_at: new Date().toISOString(), + }; + if (!owner.start_time) throw runtimeLockError('The DSH web runtime owner process could not be identity-verified.'); + + while (true) { + const existing = await readWebRuntimeLock(); + if (existing) { + const active = await activeWebJob(); + const ownerAlive = await processIdentityAlive(existing.record); + if (active) { + throw new ToolError( + 'workspace_busy', + `A managed DSH web runtime is already active: ${active.id}`, + ); + } + if (existing.record.state === 'starting' && ownerAlive) { + throw new ToolError( + 'workspace_busy', + 'Another Co-Engineer process is starting the DSH web runtime; retry after startup completes.', + ); + } + // An active lock with no active job is a completed or failed startup. + // Reclaim only this exact inode; a concurrent replacement is left + // untouched and the next loop observes its owner. + await removeStateFile( + stateHandle, + DSH_WEB_LOCK_FILE, + { expectedIdentity: existing.identity }, + ); + continue; + } + + const created = await createExclusiveStateFile(stateHandle, DSH_WEB_LOCK_FILE); + if (!created.created) continue; + try { + await created.file.write(`${JSON.stringify(owner)}\n`, 0, 'utf8'); + await created.file.sync(); + await inspectStateFile(stateHandle, DSH_WEB_LOCK_FILE, { expectedIdentity: created.identity }); + return { file: created.file, identity: created.identity, record: owner }; + } catch (error) { + await created.file.close().catch(() => {}); + await removeStateFile( + stateHandle, + DSH_WEB_LOCK_FILE, + { expectedIdentity: created.identity }, + ).catch(() => {}); + throw error; + } + } +} + +async function promoteWebRuntimeLock(lock, jobId) { + if (!lock) return; + await writeWebRuntimeLock(lock, { + ...lock.record, + state: 'active', + job_id: jobId, + activated_at: new Date().toISOString(), + }); +} + let database; async function exists(file) { @@ -297,7 +485,7 @@ function publicJobKind(kind) { function isAgentJobKind(kind) { return kind === 'deepseek_agent' || kind === 'dsh_agent' - || kind === 'grok_build'; + || kind === 'grok_build' || kind === 'dsh_web'; } function jobExecutionScope(job) { @@ -344,13 +532,25 @@ function executionScopesOverlap(left, right) { isPathWithin(leftPath, rightPath) || isPathWithin(rightPath, leftPath))); } -async function startAgentJob(scope, starter) { +async function listActiveJobs() { + await ensureState(); + const jobs = []; + for (const job of listActiveStoredJobs(database)) { + const reconciled = await reconcile(job); + if (ACTIVE_STATES.has(reconciled.lifecycle_state ?? reconciled.status)) jobs.push(reconciled); + } + return jobs; +} + +async function startAgentJob(scope, starter, { ignoreJobIds = [] } = {}) { + const ignored = new Set(ignoreJobIds); let release; const previous = agentSubmissionTail; agentSubmissionTail = new Promise((resolve) => { release = resolve; }); await previous; try { - const active = (await listJobs(100)).find((job) => isAgentJobKind(job.kind) + const active = (await listActiveJobs()).find((job) => isAgentJobKind(job.kind) + && !ignored.has(job.id) && ACTIVE_STATES.has(job.status) && executionScopesOverlap(jobExecutionScope(job), scope)); if (active) { @@ -445,6 +645,889 @@ async function targetGitMetadata(cwd) { }; } +function sourceRef(value) { + if (value === undefined || value === null) return null; + if (typeof value !== 'string' || value.length < 1 || value.length > 240 + || value.includes('\0') || /\s/.test(value) || value.startsWith('-')) { + throw new ToolError( + 'invalid_target_source', + 'target_context.source.ref must be a non-empty Git ref without whitespace, NUL bytes, or a leading dash.', + ); + } + return value; +} + +function githubRepository(value) { + if (typeof value !== 'string' || value.length < 1 || value.length > 240 || value.includes('\0')) { + throw new ToolError( + 'invalid_target_source', + 'target_context.source.repository must be a GitHub HTTPS URL.', + ); + } + let parsed; + try { parsed = new URL(value); } catch { + throw new ToolError('invalid_target_source', 'target_context.source.repository must be a GitHub HTTPS URL.'); + } + const hostname = parsed.hostname.toLowerCase(); + if (parsed.protocol !== 'https:' + || !['github.com', 'www.github.com'].includes(hostname) + || parsed.username || parsed.password || parsed.search || parsed.hash + || !/^\/[^/]+\/[^/]+(?:\.git)?\/?$/.test(parsed.pathname)) { + throw new ToolError( + 'invalid_target_source', + 'target_context.source.repository must be an https://github.com/OWNER/REPOSITORY URL without credentials, query, or fragment data.', + ); + } + return `https://github.com/${parsed.pathname.slice(1).replace(/\/$/, '')}`; +} + +function stageGitEnvironment() { + return { + ...process.env, + // Staging must never wait for an interactive credential prompt. Existing + // user/session credentials may still be used by Git's configured helper. + GIT_TERMINAL_PROMPT: '0', + }; +} + +function stageDeadlineError() { + return new ToolError('target_stage_timeout', 'Target staging exceeded its bounded preparation deadline.'); +} + +function assertStageDeadlineAt(deadlineAt) { + if (!Number.isFinite(deadlineAt)) return TARGET_STAGE_GIT_TIMEOUT_MS; + if (Date.now() >= deadlineAt) throw stageDeadlineError(); + return Math.max(1, Math.ceil(deadlineAt - Date.now())); +} + +function stageProcessGroupExists(pid) { + if (process.platform === 'win32' || !Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(-pid, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } +} + +function signalStageProcessGroup(pid, signal) { + if (process.platform === 'win32' || !Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(-pid, signal); + return true; + } catch (error) { + return error?.code === 'ESRCH'; + } +} + +function waitStageChild(child, timeoutMs) { + if (!child || child.exitCode !== null || child.signalCode) return Promise.resolve(true); + return new Promise((resolve) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + resolve(false); + }, Math.max(1, timeoutMs)); + const done = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(true); + }; + child.once('close', done); + child.once('exit', done); + }); +} + +async function terminateStageProcess(child) { + if (!child) return; + if (process.platform !== 'win32' && Number.isInteger(child.pid) && child.pid > 0) { + signalStageProcessGroup(child.pid, 'SIGTERM'); + if (await waitStageChild(child, 250) && !stageProcessGroupExists(child.pid)) return; + signalStageProcessGroup(child.pid, 'SIGKILL'); + await waitStageChild(child, 250); + return; + } + try { child.kill?.('SIGTERM'); } catch { /* bounded fallback */ } + if (await waitStageChild(child, 250)) return; + try { child.kill?.('SIGKILL'); } catch { /* bounded fallback */ } + await waitStageChild(child, 250); +} + +async function runStageGit(args, label, deadlineAt) { + const timeoutMs = Math.min(TARGET_STAGE_GIT_TIMEOUT_MS, assertStageDeadlineAt(deadlineAt)); + return new Promise((resolve, reject) => { + let child; + let settled = false; + let timedOut = false; + let outputBytes = 0; + const stdout = []; + const stderr = []; + const finish = (error, value = '') => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error) reject(error); + else resolve(value); + }; + const failAndTerminate = async (error) => { + if (settled) return; + await terminateStageProcess(child); + finish(error); + }; + const timer = setTimeout(() => { + timedOut = true; + void failAndTerminate(stageDeadlineError()); + }, timeoutMs); + try { + child = spawn('git', args, { + env: stageGitEnvironment(), + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + windowsHide: true, + }); + } catch (error) { + finish(new ToolError('target_stage_failed', `${label} could not be started: ${error?.message ?? 'spawn failed'}`)); + return; + } + const collect = (target, chunk) => { + if (settled) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + outputBytes += buffer.length; + if (outputBytes > 1024 * 1024) { + void failAndTerminate(new ToolError('target_stage_failed', `${label} exceeded the bounded output limit.`)); + return; + } + target.push(buffer); + }; + child.stdout?.on('data', (chunk) => collect(stdout, chunk)); + child.stderr?.on('data', (chunk) => collect(stderr, chunk)); + child.once('error', (error) => { + void failAndTerminate(new ToolError('target_stage_failed', `${label} failed: ${error?.message ?? 'process error'}`)); + }); + child.once('close', (status, signal) => { + if (settled) return; + if (timedOut || Date.now() >= deadlineAt) { + void failAndTerminate(stageDeadlineError()); + return; + } + const stdoutText = Buffer.concat(stdout).toString('utf8'); + const stderrText = Buffer.concat(stderr).toString('utf8'); + if (status !== 0) { + const detail = concise(stderrText || stdoutText || `git exited with ${signal ?? status}`, 360); + finish(new ToolError('target_stage_failed', `${label} failed${detail ? `: ${detail}` : '.'}`)); + return; + } + finish(null, stdoutText.trim()); + }); + }); +} + +async function optionalStageGit(args, deadlineAt) { + try { + return { ok: true, output: await runStageGit(args, 'Git ref query', deadlineAt), error: '' }; + } catch (error) { + if (error?.code === 'target_stage_timeout') throw error; + return { ok: false, output: '', error: error?.message ?? 'git failed' }; + } +} + +async function assertStageSize(directory, deadlineAt) { + const pending = [directory]; + let entries = 0; + let bytes = 0; + while (pending.length > 0) { + assertStageDeadlineAt(deadlineAt); + const current = pending.pop(); + const children = await readdir(current, { withFileTypes: true }); + for (const child of children) { + assertStageDeadlineAt(deadlineAt); + entries += 1; + if (entries > TARGET_STAGE_MAX_ENTRIES) { + throw new ToolError('target_stage_too_large', 'The staged checkout exceeds the bounded entry limit.'); + } + const childPath = path.join(current, child.name); + const metadata = await lstat(childPath); + assertStageDeadlineAt(deadlineAt); + if (metadata.isSymbolicLink()) { + throw new ToolError('target_stage_failed', `The staged checkout contains a symbolic link: ${childPath}`); + } + if (metadata.isDirectory()) pending.push(childPath); + else bytes += metadata.size; + if (bytes > TARGET_STAGE_MAX_BYTES) { + throw new ToolError('target_stage_too_large', 'The staged checkout exceeds the bounded byte limit.'); + } + } + } +} + +async function sourceGitRoot(sourcePath, deadlineAt) { + const metadata = await stageTargetGitMetadata(sourcePath, deadlineAt); + const status = await runStageGit([ + '-C', metadata.root, + 'status', '--porcelain=v1', '--untracked-files=all', '--ignored=no', + ], 'local source status', deadlineAt); + if (status) { + throw new ToolError( + 'target_source_dirty', + 'The local source checkout has uncommitted or untracked files; commit the review state or use a clean GitHub ref before staging.', + ); + } + const roots = configuredTargetRoots(); + if (roots && !roots.some((root) => isPathWithin(root, metadata.root))) { + throw new ToolError('target_outside_allowlist', 'The local source Git root is outside the administrator-configured target roots.'); + } + return metadata; +} + +function fullCommit(value, label) { + const commit = String(value ?? '').trim().split(/\s+/, 1)[0]; + if (!/^[0-9a-f]{40}$/i.test(commit)) { + throw new ToolError('target_stage_failed', `${label} did not resolve to a full Git commit.`); + } + return commit.toLowerCase(); +} + +async function stageGitCommonDirectory(gitRoot, deadlineAt) { + const result = await optionalStageGit([ + '-C', gitRoot, + 'rev-parse', '--git-common-dir', + ], deadlineAt); + if (!result.ok || !result.output) return gitRoot; + const candidate = path.resolve(gitRoot, result.output); + return realpath(candidate).catch(() => gitRoot); +} + +async function stageTargetGitMetadata(cwd, deadlineAt) { + const rootCandidate = await runStageGit( + ['-C', cwd, 'rev-parse', '--show-toplevel'], + 'working_directory inspection', + deadlineAt, + ); + const resolvedRoot = await realpath(rootCandidate).catch(() => { + throw new ToolError('invalid_target_context', 'The Git root does not resolve to an existing directory.'); + }); + const head = await runStageGit( + ['-C', cwd, 'rev-parse', 'HEAD'], + 'working_directory inspection', + deadlineAt, + ); + if (!/^[0-9a-f]{40}$/i.test(head)) { + throw new ToolError('invalid_target_context', 'Git HEAD is not a full 40-character revision.'); + } + return { + root: resolvedRoot, + head: head.toLowerCase(), + common: await stageGitCommonDirectory(resolvedRoot, deadlineAt), + }; +} + +async function refCandidates(root, ref, deadlineAt) { + if (!ref) return []; + if (/^[0-9a-f]{40}$/i.test(ref)) { + const verified = await optionalStageGit(['-C', root, 'cat-file', '-e', `${ref}^{commit}`], deadlineAt); + if (!verified.ok) throw new ToolError('target_stage_ref_not_found', `Local source ref ${ref} was not found.`); + return [{ ref, commit: ref.toLowerCase(), direct: true }]; + } + const normalized = ref.startsWith('refs/') ? ref : null; + const names = normalized + ? [normalized] + : [`refs/heads/${ref}`, `refs/tags/${ref}`, `refs/remotes/origin/${ref}`]; + const result = await optionalStageGit([ + '-C', root, + 'for-each-ref', + '--format=%(refname) %(objectname)', + ...names.map((name) => name), + ], deadlineAt); + if (!result.ok) { + throw new ToolError('target_stage_ref_not_found', `Local source ref ${ref} could not be resolved.`); + } + const output = result.output; + return output.split(/\r?\n/) + .map((line) => { + const [refName, objectName] = line.trim().split(/\s+/, 2); + return refName && objectName ? { ref: refName, object: objectName } : null; + }) + .filter(Boolean); +} + +async function resolveLocalRef(root, ref, fallbackHead, deadlineAt) { + if (!ref) return { commit: fallbackHead, ref: null }; + const candidates = await refCandidates(root, ref, deadlineAt); + if (candidates.length === 0) { + throw new ToolError('target_stage_ref_not_found', `Local source ref ${ref} was not found.`); + } + const resolved = []; + for (const candidate of candidates) { + resolved.push({ + ...candidate, + commit: candidate.direct + ? candidate.commit + : fullCommit(await runStageGit([ + '-C', root, + 'rev-parse', '--verify', `${candidate.ref}^{commit}`, + ], 'local source ref resolution', deadlineAt), 'local source ref'), + }); + } + const distinctCommits = new Set(resolved.map((candidate) => candidate.commit)); + if (resolved.length !== 1 || distinctCommits.size !== 1) { + throw new ToolError( + 'target_stage_ref_ambiguous', + `Local source ref ${ref} is ambiguous; use an exact refs/heads/* or refs/tags/* name.`, + ); + } + return { commit: resolved[0].commit, ref: resolved[0].ref }; +} + +function parseRemoteRefs(output) { + return output.split(/\r?\n/).map((line) => { + const [object, ref] = line.trim().split(/\s+/, 2); + return object && ref && /^[0-9a-f]{40}$/i.test(object) ? { object: object.toLowerCase(), ref } : null; + }).filter(Boolean); +} + +async function resolveGithubRef(repository, ref, deadlineAt) { + if (ref && /^[0-9a-f]{40}$/i.test(ref)) { + return { commit: ref.toLowerCase(), ref: ref.toLowerCase() }; + } + const names = ref?.startsWith('refs/') + ? [ref] + : ref + ? [`refs/heads/${ref}`, `refs/tags/${ref}`] + : ['HEAD']; + const matches = []; + for (const name of names) { + const query = name.startsWith('refs/tags/') + ? [name, `${name}^{}`] + : [name]; + const remoteResult = await optionalStageGit(['ls-remote', repository, ...query], deadlineAt); + if (!remoteResult.ok) { + throw new ToolError('target_stage_failed', 'GitHub source ref resolution failed.'); + } + const remote = parseRemoteRefs(remoteResult.output); + const exact = remote.filter((candidate) => candidate.ref === name); + const peeled = remote.filter((candidate) => candidate.ref === `${name}^{}`); + if (exact.length > 1 || peeled.length > 1) { + throw new ToolError( + 'target_stage_ref_ambiguous', + `GitHub source ref ${ref ?? 'HEAD'} returned multiple exact or peeled objects.`, + ); + } + if (exact.length !== 1) continue; + // Annotated tags must bind to the peeled commit advertised by the + // remote. Lightweight tags and branches have no ^{} row and retain the + // exact object they advertise. + matches.push({ ref: name, commit: peeled[0]?.object ?? exact[0].object }); + } + if (matches.length === 0) throw new ToolError('target_stage_ref_not_found', `GitHub source ref ${ref ?? 'HEAD'} was not found.`); + const distinctCommits = new Set(matches.map((candidate) => candidate.commit)); + if (matches.length !== 1 || distinctCommits.size !== 1) { + throw new ToolError( + 'target_stage_ref_ambiguous', + `GitHub source ref ${ref} is ambiguous; use an exact refs/heads/* or refs/tags/* name.`, + ); + } + return matches[0]; +} + +function leaseDescriptor(source, repository, ref, resolvedHead) { + return { + type: source.type, + repository, + ref: ref ?? 'HEAD', + resolved_head: resolvedHead, + }; +} + +function sameFsIdentity(left, right) { + return Boolean(left && right) + && String(left.dev ?? left.device) === String(right.dev ?? right.device) + && String(left.ino ?? left.inode) === String(right.ino ?? right.inode); +} + +async function leaseDirectoryIdentity(directory, label = 'target staging lease') { + const metadata = await lstat(directory).catch(() => null); + if (!metadata || !metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new ToolError('target_stage_failed', `${label} is not a private directory: ${directory}`); + } + return { dev: String(metadata.dev), ino: String(metadata.ino) }; +} + +async function readLeaseChild(leaseDirectory, name, expectedDirectoryIdentity) { + const leaseHandle = await prepareStateDirectory(leaseDirectory); + if (!sameFsIdentity(leaseHandle.components.at(-1), expectedDirectoryIdentity)) { + throw new ToolError('target_stage_identity_changed', `Target staging lease changed while reading ${name}.`); + } + const identity = await inspectStateFile(leaseHandle, name, { required: false }); + if (!identity) return null; + const opened = await openStateFileRead(leaseHandle, name, { expectedIdentity: identity }); + try { + return { value: JSON.parse(await opened.file.readFile('utf8')), identity }; + } catch { + return null; + } finally { + await opened.file.close(); + } +} + +async function writeLease(leaseFile, value, { expectedDirectoryIdentity = null } = {}) { + const leaseDirectory = path.dirname(leaseFile); + const before = await leaseDirectoryIdentity(leaseDirectory); + if (expectedDirectoryIdentity && !sameFsIdentity(before, expectedDirectoryIdentity)) { + throw new ToolError('target_stage_identity_changed', 'Target staging lease changed before metadata publication.'); + } + const temporary = `${leaseFile}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`; + try { + await writeFile(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: 'wx' }); + const beforeRename = await leaseDirectoryIdentity(leaseDirectory); + if (!sameFsIdentity(before, beforeRename) + || (expectedDirectoryIdentity && !sameFsIdentity(beforeRename, expectedDirectoryIdentity))) { + throw new ToolError('target_stage_identity_changed', 'Target staging lease changed before metadata publication.'); + } + await rename(temporary, leaseFile); + const after = await leaseDirectoryIdentity(leaseDirectory); + if (!sameFsIdentity(before, after) + || (expectedDirectoryIdentity && !sameFsIdentity(after, expectedDirectoryIdentity))) { + throw new ToolError('target_stage_identity_changed', 'Target staging lease changed during metadata publication.'); + } + } finally { + await rm(temporary, { force: true }).catch(() => {}); + } +} + +async function writeLeaseOwner(leaseDirectory, expectedDirectoryIdentity) { + const leaseHandle = await prepareStateDirectory(leaseDirectory); + if (!sameFsIdentity(leaseHandle.components.at(-1), expectedDirectoryIdentity)) { + throw new ToolError('target_stage_identity_changed', 'Target staging lease changed before ownership publication.'); + } + const owner = await createExclusiveStateFile(leaseHandle, 'owner.json'); + if (!owner.created) { + await owner.file?.close().catch(() => {}); + throw new ToolError('target_stage_busy', 'Another control-plane operation owns this target staging lease.'); + } + const record = { + schema_version: 'codex-co-engineer.target-lease-owner.v1', + pid: process.pid, + start_time: await currentControlProcessStartTime(), + created_at: new Date().toISOString(), + }; + if (!record.start_time) { + await owner.file.close().catch(() => {}); + await removeStateFile(leaseHandle, 'owner.json', { expectedIdentity: owner.identity }).catch(() => {}); + throw new ToolError('target_stage_failed', 'Target staging owner process could not be identity-verified.'); + } + try { + await owner.file.write(`${JSON.stringify(record)}\n`, 0, 'utf8'); + await owner.file.sync(); + await inspectStateFile(leaseHandle, 'owner.json', { expectedIdentity: owner.identity }); + } finally { + await owner.file.close(); + } + return { record, identity: owner.identity }; +} + +async function removeLeaseChild(leaseDirectory, name, expectedDirectoryIdentity) { + const leaseHandle = await prepareStateDirectory(leaseDirectory); + if (!sameFsIdentity(leaseHandle.components.at(-1), expectedDirectoryIdentity)) return false; + const identity = await inspectStateFile(leaseHandle, name, { required: false }); + if (!identity) return false; + await removeStateFile(leaseHandle, name, { expectedIdentity: identity }); + return true; +} + +async function removeLeaseDirectory(stageRoot, directory, expectedIdentity) { + await revalidateStateDirectory(stageRoot); + const observed = await lstat(directory).catch(() => null); + if (!observed || !observed.isDirectory() || observed.isSymbolicLink() + || !sameFsIdentity(observed, expectedIdentity)) return false; + // Revalidate both the parent and the exact lease inode immediately before + // removal. A slow clone can outlive a stale-lock check; if another owner + // replaced this path, its inode is left untouched. + await revalidateStateDirectory(stageRoot); + const confirmed = await lstat(directory).catch(() => null); + if (!confirmed || !sameFsIdentity(confirmed, expectedIdentity)) return false; + await rm(directory, { recursive: true, force: true }); + const remaining = await lstat(directory).catch(() => null); + if (remaining && sameFsIdentity(remaining, expectedIdentity)) return false; + return true; +} + +async function targetLeaseOwner(leaseDirectory, expectedDirectoryIdentity) { + const owner = await readLeaseChild(leaseDirectory, 'owner.json', expectedDirectoryIdentity).catch(() => null); + if (!owner?.value) return { record: null, alive: false }; + const record = owner.value; + const valid = record.schema_version === 'codex-co-engineer.target-lease-owner.v1' + && Number.isInteger(record.pid) + && record.pid >= 2 + && typeof record.start_time === 'string' + && record.start_time.length > 0; + return { record: valid ? record : null, alive: valid && await processIdentityAlive(record) }; +} + +async function activeStageCheckouts(deadlineAt) { + if (!database) return new Map(); + const active = new Map(); + // Pruning must not trust a dead runner's stale active row. Reconcile a + // bounded number of oldest rows before deciding which staged checkout is + // protected by a live job. + for (const stored of listActiveStoredJobs(database, TARGET_STAGE_RECONCILE_LIMIT)) { + assertStageDeadlineAt(deadlineAt); + const job = await reconcile(stored); + assertStageDeadlineAt(deadlineAt); + if (!ACTIVE_STATES.has(job.lifecycle_state ?? job.status)) continue; + const target = storedJson(job.target_context); + if (target?.target_origin === 'control_plane_staged' && target.working_directory) { + active.set(path.resolve(target.working_directory), target.workspace_identity ?? null); + } + } + return active; +} + +async function pruneTargetLeases(stageRoot, deadlineAt) { + assertStageDeadlineAt(deadlineAt); + await revalidateStateDirectory(stageRoot); + const active = await activeStageCheckouts(deadlineAt); + const entries = await readdir(stageRoot.directory, { withFileTypes: true }).catch(() => []); + const leases = []; + for (const entry of entries) { + assertStageDeadlineAt(deadlineAt); + if (!entry.isDirectory() || !entry.name.startsWith('lease-')) continue; + const directory = path.join(stageRoot.directory, entry.name); + const checkout = path.join(directory, 'checkout'); + const observed = await lstat(directory).catch(() => null); + if (!observed || observed.isSymbolicLink() || !observed.isDirectory()) continue; + const directoryIdentity = { dev: String(observed.dev), ino: String(observed.ino) }; + const leaseChild = await readLeaseChild(directory, 'lease.json', directoryIdentity).catch(() => null); + const lease = leaseChild?.value ?? null; + const owner = await targetLeaseOwner(directory, directoryIdentity); + const expectedActiveIdentity = active.get(path.resolve(checkout)); + const checkoutObserved = await lstat(checkout).catch(() => null); + const checkoutIdentity = checkoutObserved && !checkoutObserved.isSymbolicLink() + ? { dev: String(checkoutObserved.dev), ino: String(checkoutObserved.ino) } + : null; + const lastUsed = Date.parse(lease?.last_used_at ?? '') || observed?.mtimeMs || 0; + const activeIdentityMatches = expectedActiveIdentity + && checkoutIdentity + && sameFsIdentity(expectedActiveIdentity, checkoutIdentity); + leases.push({ + directory, + checkout, + directoryIdentity, + lastUsed, + active: Boolean(activeIdentityMatches), + ownerAlive: owner.alive, + valid: Boolean(lease), + }); + } + const now = Date.now(); + const expired = leases + .filter((lease) => !lease.active && !lease.ownerAlive && (lease.valid + ? now - lease.lastUsed > TARGET_STAGE_LEASE_TTL_MS + : now - lease.lastUsed > TARGET_STAGE_LOCK_STALE_MS)) + .sort((left, right) => left.lastUsed - right.lastUsed); + for (const lease of expired) await removeLeaseDirectory( + stageRoot, + lease.directory, + lease.directoryIdentity, + ).catch(() => {}); + assertStageDeadlineAt(deadlineAt); + const survivors = leases.filter((lease) => !expired.includes(lease)); + const overQuota = survivors + // A recent entry without lease.json is an in-progress clone. Never evict + // that lock merely because the quota is full; stale locks are reclaimed + // by the TTL branch above. + .filter((lease) => !lease.active && !lease.ownerAlive && lease.valid) + .sort((left, right) => left.lastUsed - right.lastUsed) + .slice(0, Math.max(0, survivors.length - TARGET_STAGE_MAX_LEASES)); + for (const lease of overQuota) await removeLeaseDirectory( + stageRoot, + lease.directory, + lease.directoryIdentity, + ).catch(() => {}); + assertStageDeadlineAt(deadlineAt); + await revalidateStateDirectory(stageRoot); +} + +async function stagedCheckoutClean(checkout, deadlineAt) { + try { + const result = await runStageGit([ + '-C', checkout, + 'status', '--porcelain=v1', '--untracked-files=all', '--ignored=matching', + ], 'staged checkout status', deadlineAt); + return !result; + } catch (error) { + if (error?.code === 'target_stage_timeout') throw error; + return false; + } +} + +async function existingStageLease( + leaseDirectory, + leaseFile, + checkout, + descriptorDigest, + resolvedHead, + sourceType, + deadlineAt, +) { + const directoryMetadata = await lstat(leaseDirectory).catch(() => null); + if (!directoryMetadata?.isDirectory() || directoryMetadata.isSymbolicLink()) return null; + const leaseIdentity = { dev: String(directoryMetadata.dev), ino: String(directoryMetadata.ino) }; + const owner = await targetLeaseOwner(leaseDirectory, leaseIdentity); + if (owner.record && owner.alive) return null; + const leaseChild = await readLeaseChild(leaseDirectory, 'lease.json', leaseIdentity).catch(() => null); + const lease = leaseChild?.value ?? null; + if (!lease || lease.descriptor_digest !== descriptorDigest || lease.resolved_head !== resolvedHead + || lease.source_type !== sourceType || lease.tainted === true) return null; + if (await exists(path.join(leaseDirectory, 'tainted'))) return null; + const metadata = await stageTargetGitMetadata(checkout, deadlineAt).catch((error) => { + if (error?.code === 'target_stage_timeout') throw error; + return null; + }); + if (!metadata || metadata.root !== path.resolve(checkout) || metadata.head !== resolvedHead) return null; + if (!await stagedCheckoutClean(checkout, deadlineAt)) return null; + const checkoutIdentity = await directoryIdentity(checkout, 'staged checkout').catch(() => null); + if (!checkoutIdentity || (lease.checkout_identity && !sameFsIdentity(lease.checkout_identity, checkoutIdentity))) return null; + const refreshed = { ...lease, last_used_at: new Date().toISOString() }; + await writeLease(leaseFile, refreshed, { expectedDirectoryIdentity: leaseIdentity }); + return { + directory: path.resolve(checkout), + head: resolvedHead, + common: metadata.common, + source_type: sourceType, + lease_directory: path.resolve(leaseDirectory), + lease_identity: leaseIdentity, + taint_file: path.join(leaseDirectory, 'tainted'), + }; +} + +async function stageTargetSource(source) { + const stageDeadlineAt = Date.now() + TARGET_STAGE_DEADLINE_MS; + if (!source || typeof source !== 'object' || Array.isArray(source)) { + throw new ToolError('invalid_target_source', 'target_context.source must identify a local checkout or GitHub repository.'); + } + for (const key of Object.keys(source)) { + if (!TARGET_SOURCE_KEYS.has(key)) { + throw new ToolError('invalid_target_source', `target_context.source.${key} is not supported.`); + } + } + if (!TARGET_SOURCE_TYPES.has(source.type)) { + throw new ToolError('invalid_target_source', 'target_context.source.type must be local or github.'); + } + const ref = sourceRef(source.ref); + let repository; + let sourceMetadata = null; + if (source.type === 'local') { + if (Object.hasOwn(source, 'repository')) { + throw new ToolError('invalid_target_source', 'Local sources must use path, not repository.'); + } + if (typeof source.path !== 'string' || !path.isAbsolute(source.path) || source.path.includes('\0')) { + throw new ToolError('invalid_target_source', 'target_context.source.path must be an absolute local Git path.'); + } + sourceMetadata = await sourceGitRoot(await realpath(source.path).catch(() => { + throw new ToolError('invalid_target_source', 'target_context.source.path does not resolve to a local Git checkout.'); + }), stageDeadlineAt); + const writableSourceRoot = GROK_READ_ONLY_WRITABLE_ROOTS.find((root) => isPathWithin(root, sourceMetadata.root)); + if (writableSourceRoot) { + throw new ToolError( + 'target_source_unverifiable', + `The local source Git root is beneath ${writableSourceRoot}, where the built-in provider profile permits writes; move the source to a non-temporary root or use a GitHub source for staged review.`, + ); + } + repository = sourceMetadata.root; + } else { + if (Object.hasOwn(source, 'path')) { + throw new ToolError('invalid_target_source', 'GitHub sources must use repository, not path.'); + } + repository = githubRepository(source.repository); + } + + assertStageDeadlineAt(stageDeadlineAt); + await ensureState(); + assertStageDeadlineAt(stageDeadlineAt); + const stateWritableRoot = GROK_READ_ONLY_WRITABLE_ROOTS.find((root) => STATE_DIR && isPathWithin(root, STATE_DIR)); + if (stateWritableRoot) { + throw new ToolError( + 'target_stage_state_unverifiable', + `Staged targets cannot use a state directory beneath ${stateWritableRoot}; the provider's built-in read-only profile permits writes there. Configure a non-temporary Co-Engineer state directory.`, + ); + } + const stageRoot = await prepareStateDirectory(path.join(STATE_DIR, TARGET_STAGE_ROOT_NAME)); + await revalidateStateDirectory(stageRoot); + assertStageDeadlineAt(stageDeadlineAt); + await pruneTargetLeases(stageRoot, stageDeadlineAt); + assertStageDeadlineAt(stageDeadlineAt); + const resolved = source.type === 'local' + ? await resolveLocalRef(sourceMetadata.root, ref, sourceMetadata.head, stageDeadlineAt) + : await resolveGithubRef(repository, ref, stageDeadlineAt); + assertStageDeadlineAt(stageDeadlineAt); + const resolvedHead = resolved.commit; + const resolvedRef = resolved.ref; + const descriptor = leaseDescriptor(source, repository, ref, resolvedHead); + const descriptorDigest = sha256Digest(descriptor); + const leaseDirectory = path.join(stageRoot.directory, `lease-${descriptorDigest}`); + const leaseFile = path.join(leaseDirectory, 'lease.json'); + const checkout = path.join(leaseDirectory, 'checkout'); + const acquireStarted = Date.now(); + let acquired = false; + let leaseIdentity = null; + + // A deterministic lease makes preflight and the subsequent run observe the + // same checkout. A second caller waits for an in-progress clone, then + // reuses it; a dead owner is reclaimed only after a bounded stale period. + while (!acquired) { + try { + await mkdir(leaseDirectory, { mode: 0o700 }); + leaseIdentity = await leaseDirectoryIdentity(leaseDirectory); + const owner = await writeLeaseOwner(leaseDirectory, leaseIdentity); + if (!owner.record.start_time) throw new ToolError('target_stage_failed', 'Target staging owner is not verifiable.'); + acquired = true; + break; + } catch (error) { + if (error?.code !== 'EEXIST') { + if (leaseIdentity) { + await removeLeaseDirectory(stageRoot, leaseDirectory, leaseIdentity).catch(() => {}); + leaseIdentity = null; + } + if (error?.code === 'state_symlink' || error?.code === 'state_not_directory') { + throw new ToolError('target_stage_failed', `Target staging lease is not a private directory: ${leaseDirectory}`); + } + throw error; + } + const reusable = await existingStageLease( + leaseDirectory, + leaseFile, + checkout, + descriptorDigest, + resolvedHead, + source.type, + stageDeadlineAt, + ); + if (reusable) { + await revalidateStateDirectory(stageRoot); + assertStageDeadlineAt(stageDeadlineAt); + return reusable; + } + const observed = await lstat(leaseDirectory).catch(() => null); + if (!observed?.isDirectory() || observed.isSymbolicLink()) { + throw new ToolError('target_stage_failed', `Target staging lease is not a private directory: ${leaseDirectory}`); + } + const observedIdentity = { dev: String(observed.dev), ino: String(observed.ino) }; + const owner = await targetLeaseOwner(leaseDirectory, observedIdentity); + const existingLease = await readLeaseChild(leaseDirectory, 'lease.json', observedIdentity).catch(() => null); + if (existingLease?.value && !owner.alive) { + // A completed lease that no longer points at a clean, untainted + // checkout is disposable. Reclaim it by inode before cloning a fresh + // copy; never recursively remove a replacement at the same path. + await removeLeaseDirectory(stageRoot, leaseDirectory, observedIdentity); + continue; + } + // A clone may block the event loop for longer than the stale-lock TTL. + // Reclaim only when the recorded owner is absent/dead, and fence the + // recursive cleanup to the exact directory inode observed above. + if (!owner.alive && Date.now() - observed.mtimeMs > TARGET_STAGE_LOCK_STALE_MS) { + await removeLeaseDirectory(stageRoot, leaseDirectory, observedIdentity); + continue; + } + if (Date.now() - acquireStarted >= TARGET_STAGE_ACQUIRE_TIMEOUT_MS) { + throw new ToolError('target_stage_busy', 'Another control-plane operation is staging this target; retry after it completes.'); + } + assertStageDeadlineAt(stageDeadlineAt); + await sleep(200); + } + } + + let temporaryCheckout; + let temporaryCheckoutIdentity = null; + try { + assertStageDeadlineAt(stageDeadlineAt); + await revalidateStateDirectory(stageRoot); + if (!leaseIdentity || !sameFsIdentity( + await leaseDirectoryIdentity(leaseDirectory), + leaseIdentity, + )) throw new ToolError('target_stage_identity_changed', 'Target staging lease changed before cloning.'); + temporaryCheckout = await mkdtemp(path.join(leaseDirectory, 'checkout-tmp-')); + await chmod(temporaryCheckout, 0o700); + temporaryCheckoutIdentity = await leaseDirectoryIdentity(temporaryCheckout, 'temporary target checkout'); + const cloneArgs = ['clone', '--no-checkout', '--no-tags']; + if (source.type === 'local') cloneArgs.push('--no-local'); + cloneArgs.push(repository, temporaryCheckout); + await runStageGit(cloneArgs, 'target source clone', stageDeadlineAt); + assertStageDeadlineAt(stageDeadlineAt); + await assertStageSize(temporaryCheckout, stageDeadlineAt); + if (resolvedRef) { + await runStageGit(['-C', temporaryCheckout, 'fetch', '--no-tags', 'origin', resolvedRef], 'target source ref fetch', stageDeadlineAt); + assertStageDeadlineAt(stageDeadlineAt); + await runStageGit(['-C', temporaryCheckout, 'checkout', '--detach', 'FETCH_HEAD'], 'target source ref checkout', stageDeadlineAt); + } else { + await runStageGit(['-C', temporaryCheckout, 'checkout', '--detach', resolvedHead], 'target source checkout', stageDeadlineAt); + } + assertStageDeadlineAt(stageDeadlineAt); + // A staged review never needs its origin and must not expose a private + // source URL or a credential-bearing remote to the provider. + await runStageGit(['-C', temporaryCheckout, 'remote', 'remove', 'origin'], 'target source remote cleanup', stageDeadlineAt); + assertStageDeadlineAt(stageDeadlineAt); + await assertStageSize(temporaryCheckout, stageDeadlineAt); + const stagedMetadata = await stageTargetGitMetadata(temporaryCheckout, stageDeadlineAt); + if (stagedMetadata.head !== resolvedHead) { + throw new ToolError('target_stage_failed', `The staged checkout resolved ${stagedMetadata.head}, expected ${resolvedHead}.`); + } + await revalidateStateDirectory(stageRoot); + const leaseBeforePublish = await leaseDirectoryIdentity(leaseDirectory); + if (!sameFsIdentity(leaseBeforePublish, leaseIdentity)) { + throw new ToolError('target_stage_identity_changed', 'Target staging lease was replaced during cloning.'); + } + const existingCheckout = await lstat(checkout).catch(() => null); + if (existingCheckout) { + throw new ToolError('target_stage_identity_changed', 'Target staging checkout was replaced during cloning.'); + } + await rename(temporaryCheckout, checkout); + temporaryCheckout = null; + temporaryCheckoutIdentity = null; + const checkoutIdentity = await directoryIdentity(checkout, 'staged checkout'); + // Git reports an absolute common-directory path. Recompute it after the + // atomic publish so the first caller and later lease reusers bind the same + // final checkout identity rather than the temporary pre-rename pathname. + const publishedMetadata = await stageTargetGitMetadata(checkout, stageDeadlineAt); + if (publishedMetadata.head !== resolvedHead) { + throw new ToolError('target_stage_failed', `The published checkout resolved ${publishedMetadata.head}, expected ${resolvedHead}.`); + } + const now = new Date().toISOString(); + await writeLease(leaseFile, { + schema_version: 'codex-co-engineer.target-lease.v1', + descriptor_digest: descriptorDigest, + source_type: source.type, + resolved_head: resolvedHead, + created_at: now, + last_used_at: now, + checkout_identity: checkoutIdentity, + }, { expectedDirectoryIdentity: leaseIdentity }); + await removeLeaseChild(leaseDirectory, 'owner.json', leaseIdentity).catch(() => {}); + await revalidateStateDirectory(stageRoot); + return { + directory: path.resolve(checkout), + head: resolvedHead, + common: publishedMetadata.common, + source_type: source.type, + lease_directory: path.resolve(leaseDirectory), + lease_identity: leaseIdentity, + taint_file: path.join(leaseDirectory, 'tainted'), + }; + } catch (error) { + if (temporaryCheckout && temporaryCheckoutIdentity) { + const currentTemporary = await lstat(temporaryCheckout).catch(() => null); + if (currentTemporary && sameFsIdentity(currentTemporary, temporaryCheckoutIdentity)) { + await rm(temporaryCheckout, { recursive: true, force: true }).catch(() => {}); + } + } + if (acquired && leaseIdentity) { + await removeLeaseDirectory(stageRoot, leaseDirectory, leaseIdentity).catch(() => {}); + } + throw error; + } +} + async function directoryIdentity(directory, label) { const info = await stat(directory).catch(() => { throw new ToolError('invalid_target_context', `${label} does not resolve to an existing directory.`); @@ -471,7 +1554,37 @@ async function prepareTarget(rawTarget) { throw new ToolError('invalid_target_context', `target_context.schema_version must be ${TARGET_SCHEMA_VERSION}.`); } if (!TARGET_MODES.has(rawTarget.mode)) { - throw new ToolError('invalid_target_context', 'target_context.mode must be default or explicit.'); + throw new ToolError('invalid_target_context', 'target_context.mode must be default, explicit, or staged.'); + } + + let staged = null; + if (rawTarget.mode === 'staged') { + const stagedKeys = new Set(['schema_version', 'mode', 'source', 'allowed_paths', 'role']); + for (const key of Object.keys(rawTarget)) { + if (!stagedKeys.has(key)) { + throw new ToolError('invalid_target_context', `target_context.${key} is not supported when mode=staged.`); + } + } + if (rawTarget.role === 'implement') { + throw new ToolError( + 'invalid_target_context', + 'Staged targets are read-only review or verify checkouts; implement runs require an explicit workspace target.', + ); + } + staged = await stageTargetSource(rawTarget.source); + rawTarget = { + schema_version: TARGET_SCHEMA_VERSION, + mode: 'explicit', + working_directory: staged.directory, + expected_git_root: staged.directory, + expected_head: staged.head, + allowed_paths: rawTarget.allowed_paths, + role: rawTarget.role, + }; + } + + if (Object.hasOwn(rawTarget, 'source')) { + throw new ToolError('invalid_target_context', 'target_context.source is only valid when mode=staged.'); } const isDefault = rawTarget.mode === 'default'; @@ -527,7 +1640,13 @@ async function prepareTarget(rawTarget) { || (resolvedGitRoot && resolvedGitRoot !== path.resolve(expectedGitRoot))) { throw new ToolError('invalid_target_context', 'target paths may not contain symlinks.'); } - const metadata = await targetGitMetadata(resolvedWorkingDirectory); + const metadata = staged + ? { + root: resolvedGitRoot, + head: rawTarget.expected_head.toLowerCase(), + common: staged.common, + } + : await targetGitMetadata(resolvedWorkingDirectory); if (resolvedGitRoot && metadata.root !== resolvedGitRoot) { throw new ToolError('invalid_target_context', `working_directory is not inside expected_git_root (${metadata.root}).`); } @@ -539,10 +1658,10 @@ async function prepareTarget(rawTarget) { throw new ToolError('target_head_mismatch', `Expected HEAD ${rawTarget.expected_head}, found ${metadata.head}.`); } const targetRoots = configuredTargetRoots(); - if (targetRoots && !targetRoots.some((root) => isPathWithin(root, resolvedWorkingDirectory))) { + if (!staged && targetRoots && !targetRoots.some((root) => isPathWithin(root, resolvedWorkingDirectory))) { throw new ToolError('target_outside_allowlist', 'working_directory is outside the administrator-configured target roots.'); } - if (targetRoots && !targetRoots.some((root) => isPathWithin(root, exactRoot))) { + if (!staged && targetRoots && !targetRoots.some((root) => isPathWithin(root, exactRoot))) { throw new ToolError('target_outside_allowlist', 'expected_git_root is outside the administrator-configured target roots.'); } const normalizedAllowedPaths = allowedPaths.map(normalizeAllowedPath); @@ -554,6 +1673,8 @@ async function prepareTarget(rawTarget) { resolved_cwd: resolvedWorkingDirectory, git_common_directory: metadata.common, git_head: metadata.head, + allowed_paths: normalizedAllowedPaths, + role, workspace_identity: workspaceIdentity, cwd_identity: cwdIdentity, }); @@ -574,6 +1695,12 @@ async function prepareTarget(rawTarget) { target_fingerprint: targetFingerprint, workspace_identity: workspaceIdentity, cwd_identity: cwdIdentity, + ...(staged ? { target_origin: 'control_plane_staged' } : {}), + ...(staged ? { + stage_lease_directory: staged.lease_directory, + stage_lease_identity: staged.lease_identity, + stage_taint_file: staged.taint_file, + } : {}), isolation: role === 'implement' ? 'explicit-scoped-workspace' : 'read-only-process-contract', @@ -806,6 +1933,34 @@ async function tailLog(file, lineCount) { } } +async function logSuffix(file, maximumBytes = 1_048_576) { + if (!(await exists(file))) return ''; + const info = await stat(file); + const bytes = Math.min(info.size, maximumBytes); + const handle = await open(file, 'r'); + try { + const buffer = Buffer.alloc(bytes); + if (bytes > 0) await handle.read(buffer, 0, bytes, info.size - bytes); + return redactLog(buffer.toString('utf8').replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')); + } finally { + await handle.close(); + } +} + +async function terminalGrokResponse(job) { + const lifecycle = job?.lifecycle_state ?? job?.status; + if (job?.kind !== 'grok_build' || !['completed', 'succeeded'].includes(lifecycle)) return null; + const parsed = grokBuildFinalResponse(await logSuffix(job.log_file)); + if (!parsed) return null; + const configuration = storedJson(job.effective_configuration); + const outputFormat = configuration?.grok_configuration?.output_format + ?? configuration?.output_format; + return { + ...parsed, + source: outputFormat === 'json' ? 'grok_json' : 'grok_streaming_json', + }; +} + function parseCursor(value, fallback = null) { if (value === undefined || value === null || value === '') return fallback; const text = String(value); @@ -1020,7 +2175,13 @@ function getProductionCapacityReader() { return reader(jobId); }; } - productionCapacityReader = createCapacityReader({ readDshReceipt: dshReceiptReader }); + productionCapacityReader = createCapacityReader({ + // The command is administrator-selected at MCP process startup. Pass it + // explicitly so Grok capacity uses the same executable as status and run; + // the capacity helper otherwise defaults to the literal `grok` command. + readGrok: (options) => readGrokCapacity({ ...options, command: GROK }), + readDshReceipt: dshReceiptReader, + }); return productionCapacityReader; } @@ -1124,38 +2285,47 @@ async function startJob({ async function cancelJob(job) { job = await reconcile(job); if (FINAL_STATES.has(job.status)) return job; - if (!(await isOwned(job))) { - throw new ToolError('ownership_check_failed', 'Refusing to signal a process not proven to be plugin-owned.'); - } - + // Cancellation is a durable intent, not a process signal. A job can be + // accepted/started before the runner has recorded a child PID, and a stale + // or replaced PID must not make the request disappear. Persist the intent + // first; ownership proof below gates only the best-effort signal. await writeFile(job.cancel_file, `${new Date().toISOString()}\n`, { mode: 0o600 }); updateJob(database, job.id, { status: 'cancelling', updated_at: new Date().toISOString(), - signal_sent: 'SIGTERM', termination_reason: 'cancel_requested', error: 'Cancellation requested; waiting for the managed process to exit.', }); - try { process.kill(-job.child_pid, 'SIGTERM'); } catch {} + if (!(await isOwned(job))) return getJob(job.id); + + let termSignalSent = false; + try { + process.kill(-job.child_pid, 'SIGTERM'); + termSignalSent = true; + updateJob(database, job.id, { signal_sent: 'SIGTERM' }); + } catch {} await sleep(1200); - if (await isOwned(job)) { - try { process.kill(-job.child_pid, 'SIGKILL'); } catch {} - updateJob(database, job.id, { signal_sent: 'SIGKILL', forced_kill: 1 }); + if (termSignalSent && await isOwned(job)) { + try { + process.kill(-job.child_pid, 'SIGKILL'); + updateJob(database, job.id, { signal_sent: 'SIGKILL', forced_kill: 1 }); + } catch {} } await sleep(150); return getJob(job.id); } async function activeWebJob() { - const jobs = await listJobs(50); + const jobs = await listActiveJobs(); return jobs.find((job) => job.kind === 'dsh_web' && ACTIVE_STATES.has(job.status)) ?? null; } async function statusTool(args) { const recentLimit = clampInteger(args.recent_limit, 5, 0, 15, 'recent_limit'); const jobs = await listJobs(Math.max(recentLimit, 15)); - const web = jobs.find((job) => job.kind === 'dsh_web' && ACTIVE_STATES.has(job.status)); + const activeJobs = await listActiveJobs(); + const web = activeJobs.find((job) => job.kind === 'dsh_web' && ACTIVE_STATES.has(job.status)); const listening = await portOpen(); const detectedVersions = versions(); const grokStatus = grokVersionProbe(GROK, PLUGIN_ROOT, grokEnvironment()); @@ -1251,7 +2421,7 @@ async function statusTool(args) { }, versions: detectedVersions, jobs: { - active: jobs.filter((job) => ACTIVE_STATES.has(job.status)).length, + active: activeJobs.length, recent: jobs.slice(0, recentLimit).map(compactJob), }, }; @@ -1341,6 +2511,12 @@ function grokAuthDoctor() { }; } +function dshWebPermissionMode(role) { + if (role === 'implement') return 'workspace-write'; + if (role === 'review' || role === 'verify') return 'read-only'; + throw new ToolError('invalid_target_context', 'dsh_web requires a review, verify, or implement target role.'); +} + async function runtimeTool(args) { if (args.action === 'start') { if (args.schema_version !== CONFIG_SCHEMA_VERSION) { @@ -1354,44 +2530,86 @@ async function runtimeTool(args) { const managed = await activeWebJob(); const listening = await portOpen(); if (managed && listening) return { ok: true, already_running: true, job: publicJob(managed) }; - if (listening) { - throw new ToolError('port_occupied', `Port ${WEB_PORT} is occupied by an unmanaged process.`); - } - requireDeepSeekReady(); - const runtimeDshConfiguration = normalizeDshForTool(undefined); - const job = await startJob({ - kind: 'dsh_web', - summary: 'DeepSeek Harness web UI', - command: DSH, - args: [ - '--profile', 'web', - ...(DSH_PATCH_FILE ? ['--patch', DSH_PATCH_FILE] : []), - '--host', WEB_HOST, - '--port', String(WEB_PORT), - ], - env: { - ...dshWorkerEnvironment(runtimeDshConfiguration), - DSH_PERMISSION_MODE: 'workspace-write', - }, - url: `http://${WEB_HOST}:${WEB_PORT}`, - timeoutSeconds, - cwd, - targetContext: target, - effectiveConfiguration: (() => { - const configuration = { - schema_version: CONFIG_SCHEMA_VERSION, + return startAgentJob({ + working_directory: cwd, + expected_git_root: target.expected_git_root, + git_common_directory: target.git_common_directory, + }, async () => { + // Re-check the global UI/port state after acquiring the same execution + // lock used by headless agent jobs. A concurrent runtime start either + // reuses its managed listener or fails closed; it never races a second + // DSH web process into the same workspace/port. + const currentManaged = await activeWebJob(); + const currentListening = await portOpen(); + if (currentManaged && currentListening) { + return { ok: true, already_running: true, job: publicJob(currentManaged) }; + } + if (currentManaged) { + throw new ToolError('workspace_busy', `A managed DSH web runtime is already active: ${currentManaged.id}`); + } + if (currentListening) { + throw new ToolError('port_occupied', `Port ${WEB_PORT} is occupied by an unmanaged process.`); + } + requireDeepSeekReady(); + const runtimeDshConfiguration = normalizeDshForTool(undefined); + const permissionMode = dshWebPermissionMode(target.role); + let runtimeLock; + let runtimeLockPromoted = false; + try { + // The fixed web port is a process-wide resource. The SQLite job query + // and in-memory submission tail are useful diagnostics, but only this + // owner-only O_EXCL lock closes the cross-process start race. + runtimeLock = await acquireWebRuntimeLock(); + const job = await startJob({ kind: 'dsh_web', - timeout_seconds: timeoutSeconds, - working_directory: cwd, - target_fingerprint: targetFingerprint, - target_context: target, - }; - configuration.configuration_digest = sha256Digest(configuration); - return configuration; - })(), - }); - for (let attempt = 0; attempt < 20 && !(await portOpen()); attempt += 1) await sleep(100); - return { ok: true, job: publicJob(await getJob(job.id)), listening: await portOpen() }; + summary: 'DeepSeek Harness web UI', + command: DSH, + args: [ + '--profile', 'web', + ...(DSH_PATCH_FILE ? ['--patch', DSH_PATCH_FILE] : []), + '--host', WEB_HOST, + '--port', String(WEB_PORT), + ], + env: { + ...dshWorkerEnvironment(runtimeDshConfiguration), + DSH_PERMISSION_MODE: permissionMode, + }, + url: `http://${WEB_HOST}:${WEB_PORT}`, + timeoutSeconds, + cwd, + targetContext: target, + effectiveConfiguration: (() => { + const configuration = { + schema_version: CONFIG_SCHEMA_VERSION, + kind: 'dsh_web', + timeout_seconds: timeoutSeconds, + working_directory: cwd, + target_fingerprint: targetFingerprint, + target_context: target, + role: target.role, + permission_mode: permissionMode, + }; + configuration.configuration_digest = sha256Digest(configuration); + return configuration; + })(), + }); + await promoteWebRuntimeLock(runtimeLock, job.id); + runtimeLockPromoted = true; + for (let attempt = 0; attempt < 20 && !(await portOpen()); attempt += 1) await sleep(100); + const currentJob = await getJob(job.id); + // A provider that exits during startup must not leave an owner-only + // lock behind. Keep the lock for a still-running slow startup so a + // concurrent caller cannot launch a second listener. + if (!await portOpen() && FINAL_STATES.has(currentJob.status)) { + await releaseWebRuntimeLock(runtimeLock); + runtimeLock = null; + runtimeLockPromoted = false; + } + return { ok: true, job: publicJob(currentJob), listening: await portOpen() }; + } finally { + if (runtimeLock && !runtimeLockPromoted) await releaseWebRuntimeLock(runtimeLock).catch(() => {}); + } + }, { ignoreJobIds: managed ? [managed.id] : [] }); } if (args.action === 'stop') { const managed = await activeWebJob(); @@ -1441,6 +2659,24 @@ function assertTargetFingerprint(expected, actual) { } } +function bindTarget(args, targetFingerprint) { + const binding = args.target_binding ?? 'caller'; + if (binding !== 'caller' && binding !== 'control_plane') { + throw new ToolError('invalid_target_binding', 'target_binding must be control_plane when supplied.'); + } + if (binding === 'control_plane') { + // Supplying an assertion alongside the explicit control-plane binding is + // allowed and still checked; omitting it is the convenience path. + if (Object.hasOwn(args, 'expected_target_fingerprint')) { + assertTargetFingerprint(expectedTargetFingerprint(args.expected_target_fingerprint), targetFingerprint); + } + return { expected: targetFingerprint, source: 'control_plane' }; + } + const expected = expectedTargetFingerprint(args.expected_target_fingerprint); + assertTargetFingerprint(expected, targetFingerprint); + return { expected, source: 'caller' }; +} + async function findRequest(id, fingerprint) { await ensureState(); const existing = findStoredRequest(database, id); @@ -1491,6 +2727,7 @@ function agentConfiguration({ timeoutSeconds, cwd, target, + targetBinding = 'caller', dshConfiguration = null, grokConfiguration = null, }) { @@ -1507,8 +2744,11 @@ function agentConfiguration({ }, working_directory: cwd, target_fingerprint: target?.target_fingerprint ?? null, + target_binding: targetBinding, targeting_mode: target - ? target.mode === 'default' + ? target.target_origin === 'control_plane_staged' + ? 'control-plane-staged' + : target.mode === 'default' ? 'explicit-default-workspace' : (configuredTargetRoots() ? 'administrator-allowlisted' : 'explicit-target-any-git-root') : 'default-workspace', @@ -1590,6 +2830,7 @@ function preflightAllowedFields(args) { 'timeout_seconds', 'target_context', 'expected_target_fingerprint', + 'target_binding', ...DSH_CONFIGURATION_FIELDS, ...GROK_CONFIGURATION_FIELDS, ]); @@ -1608,15 +2849,14 @@ async function preflightTool(args) { if (!Object.hasOwn(args, 'target_context')) { throw new ToolError('missing_target_context', 'preflight requires a versioned target_context; use mode=default to select the configured workspace.'); } - const expectedFingerprint = expectedTargetFingerprint(args.expected_target_fingerprint); const { cwd, target, targetFingerprint } = await prepareTarget(args.target_context); - assertTargetFingerprint(expectedFingerprint, targetFingerprint); + const binding = bindTarget(args, targetFingerprint); let kind = args.kind ?? 'preflight'; if (kind !== 'preflight' && !['deepseek_agent', 'grok_build'].includes(kind)) { throw new ToolError('invalid_kind', 'preflight.kind must be deepseek_agent or grok_build.'); } - const commonFields = new Set(['schema_version', 'kind', 'request_id', 'prompt', 'timeout_seconds', 'target_context', 'expected_target_fingerprint']); + const commonFields = new Set(['schema_version', 'kind', 'request_id', 'prompt', 'timeout_seconds', 'target_context', 'expected_target_fingerprint', 'target_binding']); const kindFields = kind === 'grok_build' ? GROK_CONFIGURATION_FIELDS : kind === 'deepseek_agent' @@ -1645,6 +2885,7 @@ async function preflightTool(args) { : { timeout_seconds: clampInteger(args.timeout_seconds, 3600, 60, 21600, 'timeout_seconds') }), working_directory: cwd, target_fingerprint: targetFingerprint, + target_binding: binding.source, target_context: target, }; let grokCapabilities = null; @@ -1664,7 +2905,8 @@ async function preflightTool(args) { ok: true, schema_version: CONFIG_SCHEMA_VERSION, target_fingerprint: targetFingerprint, - expected_target_fingerprint: expectedFingerprint, + expected_target_fingerprint: binding.expected, + target_binding: binding.source, target_match: true, resolved_workspace: target.resolved_workspace, resolved_cwd: target.resolved_cwd, @@ -1701,6 +2943,7 @@ async function runTool(args) { 'timeout_seconds', 'target_context', 'expected_target_fingerprint', + 'target_binding', ...GROK_CONFIGURATION_FIELDS, ]); rejectUnsupportedRunFields(args, allowed, args.kind); @@ -1711,9 +2954,8 @@ async function runTool(args) { if (!Object.hasOwn(args, 'target_context')) { throw new ToolError('missing_target_context', 'run requires an explicit versioned target_context; use mode=default to select the configured workspace.'); } - const expectedFingerprint = expectedTargetFingerprint(args.expected_target_fingerprint); const { cwd, target, targetFingerprint } = await prepareTarget(args.target_context); - assertTargetFingerprint(expectedFingerprint, targetFingerprint); + const binding = bindTarget(args, targetFingerprint); const grokConfiguration = normalizeGrokForTool(args, target.role); assertGrokReadOnlyTarget(target); const grokCapabilities = grokCapabilityProfile(grokInput(args), target.role); @@ -1724,6 +2966,7 @@ async function runTool(args) { timeoutSeconds, cwd, target, + targetBinding: binding.source, grokConfiguration, }); const fingerprint = requestFingerprint({ @@ -1792,6 +3035,7 @@ async function runTool(args) { 'timeout_seconds', 'target_context', 'expected_target_fingerprint', + 'target_binding', 'dsh_options', ]); rejectUnsupportedRunFields(args, allowed, args.kind); @@ -1802,9 +3046,8 @@ async function runTool(args) { if (!Object.hasOwn(args, 'target_context')) { throw new ToolError('missing_target_context', 'run requires an explicit versioned target_context; use mode=default to select the configured workspace.'); } - const expectedFingerprint = expectedTargetFingerprint(args.expected_target_fingerprint); const { cwd, target, targetFingerprint } = await prepareTarget(args.target_context); - assertTargetFingerprint(expectedFingerprint, targetFingerprint); + const binding = bindTarget(args, targetFingerprint); const dshConfiguration = normalizeDshForTool(args.dsh_options); const deepseekCapabilities = dshConfiguration ? dshCapabilityProfile(dshConfiguration) @@ -1816,6 +3059,7 @@ async function runTool(args) { timeoutSeconds, cwd, target, + targetBinding: binding.source, dshConfiguration, }); const fingerprint = requestFingerprint({ @@ -1981,6 +3225,8 @@ async function jobsTool(args) { log_delta: logDelta, }; if (args.action === 'get') { + const finalResponse = await terminalGrokResponse(job); + if (finalResponse) response.final_response = finalResponse; const usage = await terminalDshUsage(job); if (usage) response.dsh_usage = usage; } @@ -2058,11 +3304,21 @@ export async function dispatchControl(name, args = {}) { export const __testing = Object.freeze({ configuredTargetRoots, + dshWebPermissionMode, executionScopesOverlap, deepseekReadiness, getProductionCapacityReader, + listActiveJobs, + readWebRuntimeLock, + acquireWebRuntimeLock, + releaseWebRuntimeLock, + runStageGit, + assertStageSize, + resolveGithubRef, + resolveLocalRef, prepareTarget, assertGrokReadOnlyTarget, + startAgentJob, startJobEnvironment, }); diff --git a/plugins/plumbob-harness-control/mcp/daemon.mjs b/plugins/plumbob-harness-control/mcp/daemon.mjs index 0f8472e..21564ef 100644 --- a/plugins/plumbob-harness-control/mcp/daemon.mjs +++ b/plugins/plumbob-harness-control/mcp/daemon.mjs @@ -38,12 +38,24 @@ const STATE_HANDLE = await prepareStateDirectory(STATE_DIR); await revalidateStateDirectory(STATE_HANDLE); const STATE_DIGEST = stateDirectoryDigest(STATE_HANDLE); const CREDENTIAL_BINDING_DIGEST = await modelApiKeyBindingDigest(); -const IDLE_SECONDS = Number.parseInt( - process.env.CODEX_CO_ENGINEER_DAEMON_IDLE_SECONDS - ?? process.env.PLUMBOB_HARNESS_DAEMON_IDLE_SECONDS - ?? '900', - 10, -); +const DEFAULT_IDLE_SECONDS = 900; +const MAX_IDLE_SECONDS = Math.floor(0x7fffffff / 1000); + +function parseIdleSeconds(environment = process.env) { + const configured = Object.hasOwn(environment, 'CODEX_CO_ENGINEER_DAEMON_IDLE_SECONDS') + ? environment.CODEX_CO_ENGINEER_DAEMON_IDLE_SECONDS + : environment.PLUMBOB_HARNESS_DAEMON_IDLE_SECONDS; + if (configured === undefined) return DEFAULT_IDLE_SECONDS; + if (typeof configured !== 'string' || !/^[1-9]\d*$/u.test(configured.trim())) { + return DEFAULT_IDLE_SECONDS; + } + const seconds = Number(configured.trim()); + return Number.isSafeInteger(seconds) && seconds <= MAX_IDLE_SECONDS + ? seconds + : DEFAULT_IDLE_SECONDS; +} + +const IDLE_SECONDS = parseIdleSeconds(); let clients = 0; let lastActivity = Date.now(); let mutationTail = Promise.resolve(); diff --git a/plugins/plumbob-harness-control/mcp/grok-build.mjs b/plugins/plumbob-harness-control/mcp/grok-build.mjs index 02f58ab..5aa5c60 100644 --- a/plugins/plumbob-harness-control/mcp/grok-build.mjs +++ b/plugins/plumbob-harness-control/mcp/grok-build.mjs @@ -94,6 +94,7 @@ const MAX_TOKEN = 128; const MAX_RULE = 240; const MAX_RULES = 32; const MAX_JSON_SCHEMA_BYTES = 16_384; +export const GROK_FINAL_RESPONSE_MAX_CHARS = 12_000; const SAFE_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:/@+=-]{0,127}$/; const SAFE_AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; const SAFE_RULE = /^[^\u0000-\u001f\u007f]{1,240}$/; @@ -638,3 +639,115 @@ export function grokBuildFailure(text) { // process exit code to classify genuinely incomplete provider sessions. return sawText ? null : blockedToolFailure; } + +function responseText(value) { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map((part) => responseText(part)).filter(Boolean).join(''); + } + if (value && typeof value === 'object') { + if (typeof value.text === 'string') return value.text; + if (typeof value.data === 'string') return value.data; + if (typeof value.content === 'string') return value.content; + if (Array.isArray(value.content)) return responseText(value.content); + if (typeof value.output === 'string') return value.output; + if (value.message) return responseText(value.message); + } + return null; +} + +/** + * Extract only the bounded final assistant response from Grok JSONL output. + * Reasoning/analysis/tool events are deliberately ignored. The control plane + * uses this for jobs-get convenience; the full provider log remains a + * separate cursor-paged diagnostic surface. + */ +export function grokBuildFinalResponse(text, maximum = GROK_FINAL_RESPONSE_MAX_CHARS) { + const source = String(text ?? ''); + const chunks = []; + let terminalResult = null; + let sawStructuredEvent = false; + let terminalSuccess = false; + for (const line of source.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith('{')) continue; + let event; + try { event = JSON.parse(trimmed); } catch { continue; } + const type = String(event?.type ?? '').toLowerCase(); + if (type) sawStructuredEvent = true; + const role = String(event?.role ?? event?.message?.role ?? '').toLowerCase(); + if (type === 'result') { + const status = String(event?.status ?? event?.state ?? event?.stopReason + ?? event?.stop_reason ?? '').toLowerCase(); + const failed = Boolean(event?.error ?? event?.failure ?? event?.exception) + || ['error', 'failed', 'failure', 'exception', 'fatal', 'cancelled', 'canceled'] + .includes(status); + if (!failed) terminalSuccess = true; + const candidate = responseText(event.result ?? event.text ?? event.data ?? event.message); + if (candidate) terminalResult = candidate; + continue; + } + if (type === 'end') { + const stopReason = String(event?.stopReason ?? event?.stop_reason ?? event?.status + ?? event?.state ?? '').toLowerCase(); + terminalSuccess = !['error', 'failed', 'failure', 'exception', 'fatal', 'blocked', + 'cancelled', 'canceled'].includes(stopReason); + continue; + } + if (type === 'text') { + if (event.reasoning === true || event.thinking === true || event.channel === 'reasoning') continue; + const candidate = responseText(event.data ?? event.text); + if (candidate) chunks.push(candidate); + continue; + } + if ((type === 'message' || type === 'assistant') + && (!role || role === 'assistant' || role === 'model')) { + if (event.reasoning === true || event.thinking === true || event.channel === 'reasoning') continue; + const candidate = responseText(event.content ?? event.text ?? event.data ?? event.message); + if (candidate) chunks.push(candidate); + } + } + // Streaming records are only a final response after a successful terminal + // event. In particular, a reasoning record has a `text` field but is not an + // assistant response, and an interrupted stream must not be presented as a + // completed report. + if (sawStructuredEvent && !terminalSuccess) return null; + // The official `--output-format json` mode emits one raw JSON document + // instead of JSONL event envelopes. Accept only a bounded final response + // field from that document; reasoning/tool payloads remain excluded. + if (!sawStructuredEvent && !terminalResult && chunks.length === 0) { + try { + const document = JSON.parse(source.trim()); + if (document && typeof document === 'object' && !Array.isArray(document)) { + const status = String(document.status ?? document.state ?? document.stopReason + ?? document.stop_reason ?? '').toLowerCase(); + const failed = Boolean(document.error ?? document.failure ?? document.exception) + || ['error', 'failed', 'failure', 'exception', 'fatal', 'blocked', 'cancelled', 'canceled'] + .includes(status); + const hasExplicitResult = Object.hasOwn(document, 'result') + || Object.hasOwn(document, 'response') + || Object.hasOwn(document, 'final_response') + || Object.hasOwn(document, 'output'); + if (!failed && hasExplicitResult) { + terminalResult = responseText( + document.result + ?? document.response + ?? document.final_response + ?? document.output, + ); + } + } + } catch { + // Streaming output is parsed line-by-line above; malformed raw JSON is + // intentionally treated as having no final response. + } + } + const value = (terminalResult ?? chunks.join('')).trim(); + if (!value) return null; + const bounded = value.length <= maximum ? value : `${value.slice(0, maximum - 1)}…`; + return { + text: bounded, + truncated: bounded.length < value.length, + characters: value.length, + }; +} diff --git a/plugins/plumbob-harness-control/mcp/grok-outer-sandbox.mjs b/plugins/plumbob-harness-control/mcp/grok-outer-sandbox.mjs index 4f74323..acd31f0 100644 --- a/plugins/plumbob-harness-control/mcp/grok-outer-sandbox.mjs +++ b/plugins/plumbob-harness-control/mcp/grok-outer-sandbox.mjs @@ -927,7 +927,7 @@ function validateTargetInput(target) { 'schema_version', 'mode', 'working_directory', 'expected_git_root', 'git_common_directory', 'expected_head', 'allowed_paths', 'role', 'target_fingerprint', 'resolved_workspace', 'resolved_cwd', 'observed_head', 'workspace_identity', 'cwd_identity', - 'isolation', + 'isolation', 'target_origin', ], 'target', [ 'working_directory', 'expected_git_root', 'git_common_directory', 'expected_head', 'allowed_paths', 'role', 'target_fingerprint', @@ -993,6 +993,8 @@ async function prepareTarget(target) { resolved_cwd: working, git_common_directory: common, git_head: observedHead, + allowed_paths: validated.allowedPaths, + role: validated.role, workspace_identity: workspaceIdentity, cwd_identity: cwdIdentity, }); diff --git a/plugins/plumbob-harness-control/mcp/preflight.mjs b/plugins/plumbob-harness-control/mcp/preflight.mjs index c6385af..85a128a 100644 --- a/plugins/plumbob-harness-control/mcp/preflight.mjs +++ b/plugins/plumbob-harness-control/mcp/preflight.mjs @@ -12,7 +12,7 @@ export const SUPPORTED_MCP_PROTOCOL_VERSIONS = Object.freeze([ export const SERVER_IDENTITY = Object.freeze({ name: 'plumbob-harness-control', - version: '2.1.2', + version: '2.2.0', }); function canonicalValue(value) { diff --git a/plugins/plumbob-harness-control/mcp/runner.mjs b/plugins/plumbob-harness-control/mcp/runner.mjs index 7d3bed1..c59973a 100644 --- a/plugins/plumbob-harness-control/mcp/runner.mjs +++ b/plugins/plumbob-harness-control/mcp/runner.mjs @@ -764,6 +764,29 @@ async function capturePatch(spec, allowedPaths) { return true; } +async function markStagedTargetTainted(spec) { + const target = spec.target_context; + const taintFile = target?.stage_taint_file; + if (target?.target_origin !== 'control_plane_staged' + || typeof taintFile !== 'string' + || !path.isAbsolute(taintFile) + || typeof target.stage_lease_directory !== 'string') return; + const parent = path.dirname(taintFile); + const parentMetadata = await lstat(parent).catch(() => null); + const expected = target.stage_lease_identity; + if (!parentMetadata?.isDirectory() || parentMetadata.isSymbolicLink() + || !expected + || String(parentMetadata.dev) !== String(expected.dev ?? expected.device) + || String(parentMetadata.ino) !== String(expected.ino ?? expected.inode)) return; + try { + await writeFile(taintFile, `${new Date().toISOString()}\n`, { mode: 0o600, flag: 'wx' }); + } catch (error) { + // Existing taint is already the fail-closed result. A replacement or + // provider-created path must never be overwritten by cleanup. + if (error?.code !== 'EEXIST') return; + } +} + async function main() { if (!specPath || !path.isAbsolute(specPath)) { process.exitCode = 2; @@ -829,11 +852,27 @@ async function main() { let outputDrainFailed = false; const deadlineExpired = () => deadlineMs !== null && Date.now() >= deadlineMs; + const cancellationMarkerPresent = () => existsSync(spec.cancel_file); const finishTerminal = (outcome, patch, payload = null) => { const result = terminalizeJob(database, spec.id, outcome, patch, payload); terminalStateCommitted = Boolean(result.changed || result.job?.terminal_state); return result; }; + const finishPreLaunchCancellation = (at = Date.now()) => finishTerminal('cancelled', { + finished_at: new Date(at).toISOString(), + elapsed_seconds: elapsedSeconds(acceptedAt, at), + termination_reason: 'cancelled_by_user', + failure_class: 'cancelled', + error: 'Cancellation was requested before the provider was launched.', + partial_output_available: 0, + log_bytes: 0, + workspace_tainted: null, + heartbeat: JSON.stringify({ + phase: 'cancelled', + termination_reason: 'cancelled_by_user', + deadline_at: deadlineAt, + }), + }, { termination_reason: 'cancelled_by_user' }); const updateHeartbeat = async (details = {}) => { const currentBytes = await fileSize(spec.log_file); const now = Date.now(); @@ -919,6 +958,15 @@ async function main() { timeoutTimer = setTimeout(onDeadline, Math.max(0, deadlineMs - Date.now())); } + // Cancellation is a durable intent and may arrive while the runner is + // still doing provider-free setup. Check it before target inspection so + // a marker persisted before provider launch never causes a provider to be + // started just because the runner had not recorded a child PID yet. + if (cancellationMarkerPresent()) { + finishPreLaunchCancellation(); + return; + } + let preflight; try { preflight = await preflightTarget(spec); @@ -956,6 +1004,16 @@ async function main() { return; } + // This is the launch barrier. Keep the check immediately adjacent to + // spawn: cancellation can be persisted after preflight completes but + // before the child exists. A second post-spawn check below covers the + // unavoidable syscall interval and signals the new process group without + // waiting for a later heartbeat or exit event. + if (cancellationMarkerPresent()) { + finishPreLaunchCancellation(); + return; + } + if (deadlineExpired()) { onDeadline(); finishTerminal('timeout', { @@ -1006,6 +1064,15 @@ async function main() { stdio: ['ignore', 'pipe', 'pipe'], }); + // If cancellation won the launch race, signal the provider as soon as a + // PID is available. This intentionally happens before the lifecycle + // transition below so a cancellation persisted before child ownership is + // published cannot be stranded in the accepted/started state. + if (cancellationMarkerPresent()) { + signalSent = 'SIGTERM'; + signalProcessGroup(child.pid, 'SIGTERM'); + } + outputCaptures = [ captureSanitizedLines(child.stdout, logWriter, fragments), captureSanitizedLines(child.stderr, logWriter, fragments), @@ -1225,6 +1292,8 @@ async function main() { error = `${error ? `${error} ` : ''}The target checkout may contain partial changes; no patch artifact is trustworthy and it requires inspection before reuse.`; } + if (outcome !== 'completed') await markStagedTargetTainted(spec); + await updateHeartbeat({ terminal_candidate: outcome }); const finishedAt = Date.now(); const terminalTime = outcome === 'timeout' && deadlineMs !== null @@ -1268,6 +1337,7 @@ async function main() { const outcome = timedOut || deadlineExpired() ? 'timeout' : 'failed'; const terminationReason = outcome === 'timeout' ? 'wall_clock_timeout' : 'runner_error'; try { + if (outcome !== 'completed') await markStagedTargetTainted(spec); finishTerminal(outcome, { finished_at: outcome === 'timeout' && deadlineMs !== null ? new Date(deadlineMs).toISOString() diff --git a/plugins/plumbob-harness-control/mcp/secrets.mjs b/plugins/plumbob-harness-control/mcp/secrets.mjs index 8b9f51f..be0ffc8 100644 --- a/plugins/plumbob-harness-control/mcp/secrets.mjs +++ b/plugins/plumbob-harness-control/mcp/secrets.mjs @@ -1,18 +1,106 @@ import { createHash } from 'node:crypto'; -import { lstat, readFile, realpath } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { lstat, open, realpath } from 'node:fs/promises'; import path from 'node:path'; -const configRoot = process.env.XDG_CONFIG_HOME - ?? path.join(process.env.HOME ?? '', '.config'); +const NOFOLLOW = constants.O_NOFOLLOW; +const NONBLOCK = constants.O_NONBLOCK ?? 0; +const DISABLED_MODEL_API_KEY_FILE = '/dev/null'; +const NON_OWNER_MODEL_API_KEY_MODE = 0o077; -export const MODEL_API_KEY_FILE = process.env.CODEX_CO_ENGINEER_MODEL_API_KEY_FILE - ?? process.env.PLUMBOB_HARNESS_MODEL_API_KEY_FILE - ?? path.join(configRoot, 'codex-co-engineer', 'model-api-key'); +function nonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0 + ? value.trim() + : null; +} + +function absolutePath(value) { + const normalized = nonEmptyString(value); + return normalized && path.isAbsolute(normalized) ? path.resolve(normalized) : null; +} + +function configuredModelApiKeyFile(environment = process.env) { + // Explicit configuration is authoritative, including an invalid value. Do + // not silently fall through to a different profile when a caller supplied + // an empty or relative credential path. + for (const name of ['CODEX_CO_ENGINEER_MODEL_API_KEY_FILE', 'PLUMBOB_HARNESS_MODEL_API_KEY_FILE']) { + if (Object.prototype.hasOwnProperty.call(environment, name)) { + return absolutePath(environment[name]) ?? DISABLED_MODEL_API_KEY_FILE; + } + } + + if (Object.prototype.hasOwnProperty.call(environment, 'XDG_CONFIG_HOME')) { + const configRoot = absolutePath(environment.XDG_CONFIG_HOME); + return configRoot + ? path.join(configRoot, 'codex-co-engineer', 'model-api-key') + : DISABLED_MODEL_API_KEY_FILE; + } + + const home = absolutePath(environment.HOME); + return home + ? path.join(home, '.config', 'codex-co-engineer', 'model-api-key') + : DISABLED_MODEL_API_KEY_FILE; +} + +export const MODEL_API_KEY_FILE = configuredModelApiKeyFile(); const inheritedModelApiKey = process.env.MODEL_API_KEY?.trim() || ''; const MODEL_API_KEY_BINDING_SCHEMA = 'codex-co-engineer.model-api-key-binding.v1'; +function currentUid() { + return typeof process.getuid === 'function' ? process.getuid() : null; +} + +function assertProtectedModelApiKeyMetadata(metadata) { + const uid = currentUid(); + if (uid === null || !metadata.isFile() || metadata.isSymbolicLink() + || metadata.uid !== uid + || (metadata.mode & NON_OWNER_MODEL_API_KEY_MODE) !== 0 + || metadata.nlink !== 1) { + throw new Error('model API key file is not an owner-only, singly-linked regular file'); + } +} + +async function assertNoSymlinkAncestors(file) { + const parsed = path.parse(file); + let component = parsed.root; + const ancestors = file.slice(parsed.root.length).split(path.sep).filter(Boolean).slice(0, -1); + for (const name of ancestors) { + component = path.join(component, name); + const metadata = await lstat(component); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new Error('model API key path contains an unsafe ancestor'); + } + } +} + +async function readProtectedModelApiKey(file) { + if (!NOFOLLOW || typeof file !== 'string' || !path.isAbsolute(file)) { + throw new Error('model API key file cannot be securely opened'); + } + + let handle; + try { + // O_NOFOLLOW prevents a final-component symlink from redirecting a + // credential read. The descriptor is then checked and used for the read, + // avoiding a path-based stat/read race. + await assertNoSymlinkAncestors(file); + // The lstat preflight avoids opening a FIFO in blocking read mode. The + // descriptor check below remains authoritative if the path is replaced + // between this preflight and open(). + assertProtectedModelApiKeyMetadata(await lstat(file)); + handle = await open(file, constants.O_RDONLY | NOFOLLOW | NONBLOCK); + const metadata = await handle.stat(); + assertProtectedModelApiKeyMetadata(metadata); + const key = (await handle.readFile('utf8')).trim(); + if (!key || /[\u0000-\u001f\u007f\s]/.test(key)) throw new Error('invalid key file'); + return key; + } finally { + await handle?.close().catch(() => {}); + } +} + function metadataIdentity(metadata) { return { type: metadata.isFile() @@ -73,15 +161,14 @@ export async function modelApiKeyBindingDigest(file = MODEL_API_KEY_FILE) { .digest('hex'); } -export async function loadModelApiKey() { +export async function loadModelApiKey(file = MODEL_API_KEY_FILE) { if (inheritedModelApiKey) { process.env.MODEL_API_KEY = inheritedModelApiKey; return { available: true, source: 'environment' }; } try { - const key = (await readFile(MODEL_API_KEY_FILE, 'utf8')).trim(); - if (!key || /[\u0000-\u001f\u007f\s]/.test(key)) throw new Error('invalid key file'); + const key = await readProtectedModelApiKey(file); process.env.MODEL_API_KEY = key; return { available: true, source: 'protected_file' }; } catch { diff --git a/plugins/plumbob-harness-control/mcp/server.mjs b/plugins/plumbob-harness-control/mcp/server.mjs index 88bb4fb..501d09a 100644 --- a/plugins/plumbob-harness-control/mcp/server.mjs +++ b/plugins/plumbob-harness-control/mcp/server.mjs @@ -24,6 +24,7 @@ import { openStateFileRead, prepareStateDirectory, removeStateFile, + removeStateSocket, resolveStateDirectory, revalidateStateDirectory, stateDirectoryDigest, @@ -509,6 +510,43 @@ async function requestDaemon(name, args) { return rawRequest(name, args); } +const TARGET_SOURCE_SCHEMA = { + oneOf: [ + { + type: 'object', + properties: { + type: { const: 'local' }, + path: { type: 'string', pattern: '^/', description: 'Absolute local Git checkout path.' }, + ref: { + type: 'string', minLength: 1, maxLength: 240, + pattern: '^[^\\s\\u0000-\\u001f\\u007f-][^\\s\\u0000-\\u001f\\u007f]{0,239}$', + description: 'Optional Git ref resolved and rebound to its observed exact commit.', + }, + }, + required: ['type', 'path'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + type: { const: 'github' }, + repository: { + type: 'string', + pattern: '^https://(?:www\\.)?github\\.com/[^/]+/[^/?#]+(?:\\.git)?/?$', + description: 'GitHub HTTPS repository URL; credentials, query, and fragment data are rejected.', + }, + ref: { + type: 'string', minLength: 1, maxLength: 240, + pattern: '^[^\\s\\u0000-\\u001f\\u007f-][^\\s\\u0000-\\u001f\\u007f]{0,239}$', + description: 'Optional Git ref resolved and rebound to its observed exact commit.', + }, + }, + required: ['type', 'repository'], + additionalProperties: false, + }, + ], +}; + const TARGET_CONTEXT_SCHEMA = { oneOf: [ { @@ -558,6 +596,36 @@ const TARGET_CONTEXT_SCHEMA = { ], additionalProperties: false, }, + { + type: 'object', + properties: { + schema_version: { const: TARGET_SCHEMA_VERSION }, + mode: { const: 'staged' }, + source: TARGET_SOURCE_SCHEMA, + allowed_paths: { + type: 'array', + minItems: 1, + maxItems: 200, + items: { type: 'string' }, + }, + role: { type: 'string', enum: ['review', 'verify'] }, + }, + required: ['schema_version', 'mode', 'source'], + additionalProperties: false, + }, + ], +}; + +// The default contract remains caller-asserted. A caller may explicitly opt +// into control-plane binding when it wants the server to resolve the target, +// compute its identity digest, and bind that exact result atomically. +const TARGET_BINDING_POLICY = { + anyOf: [ + { required: ['expected_target_fingerprint'] }, + { + properties: { target_binding: { const: 'control_plane' } }, + required: ['target_binding'], + }, ], }; @@ -807,10 +875,10 @@ const DSH_KIND_FIELD_POLICY = { const TOOLS = [ { name: 'preflight', - description: 'Resolve and attest exactly one target/configuration before dispatch. The caller must supply expected_target_fingerprint; a mismatch is fatal.', + description: 'Resolve and attest exactly one target/configuration before dispatch. Caller assertions remain supported; explicitly set target_binding=control_plane when the connector should compute and bind the exact target identity.', inputSchema: { type: 'object', - allOf: [GROK_KIND_FIELD_POLICY, DSH_KIND_FIELD_POLICY], + allOf: [GROK_KIND_FIELD_POLICY, DSH_KIND_FIELD_POLICY, TARGET_BINDING_POLICY], properties: { schema_version: { const: CONFIG_SCHEMA_VERSION }, kind: { type: 'string', enum: ['preflight', 'deepseek_agent', 'grok_build'], default: 'preflight' }, @@ -823,10 +891,15 @@ const TOOLS = [ pattern: '^(sha256:)?[0-9a-fA-F]{64}$', description: 'Caller assertion for the resolved target fingerprint.', }, + target_binding: { + type: 'string', + enum: ['control_plane'], + description: 'Explicitly delegate target fingerprint computation to the control plane; target paths, Git HEAD, identities, and runner postflight checks remain mandatory.', + }, dsh_options: DSH_OPTIONS_SCHEMA, ...GROK_CONFIGURATION_PROPERTIES, }, - required: ['schema_version', 'target_context', 'expected_target_fingerprint'], + required: ['schema_version', 'target_context'], additionalProperties: false, }, annotations: { readOnlyHint: true, openWorldHint: false }, @@ -929,7 +1002,7 @@ const TOOLS = [ description: 'Queue a kind-specific Co-Engineer text task and return effective_configuration plus a stable job ID. DeepSeek uses one compact managed-headless profile; Grok Build uses the official direct headless CLI with typed controls.', inputSchema: { type: 'object', - allOf: [GROK_KIND_FIELD_POLICY, DSH_KIND_FIELD_POLICY], + allOf: [GROK_KIND_FIELD_POLICY, DSH_KIND_FIELD_POLICY, TARGET_BINDING_POLICY], properties: { schema_version: { const: CONFIG_SCHEMA_VERSION }, kind: { type: 'string', enum: ['deepseek_agent', 'grok_build'] }, @@ -945,10 +1018,15 @@ const TOOLS = [ pattern: '^(sha256:)?[0-9a-fA-F]{64}$', description: 'Caller assertion for the resolved target fingerprint; mismatch is fatal.', }, + target_binding: { + type: 'string', + enum: ['control_plane'], + description: 'Explicitly delegate target fingerprint computation to the control plane; target paths, Git HEAD, identities, and runner postflight checks remain mandatory.', + }, dsh_options: DSH_OPTIONS_SCHEMA, ...GROK_CONFIGURATION_PROPERTIES, }, - required: ['schema_version', 'kind', 'request_id', 'target_context', 'expected_target_fingerprint'], + required: ['schema_version', 'kind', 'request_id', 'target_context'], additionalProperties: false, }, annotations: { openWorldHint: true }, diff --git a/plugins/plumbob-harness-control/mcp/store.mjs b/plugins/plumbob-harness-control/mcp/store.mjs index f19b66b..2fb9995 100644 --- a/plugins/plumbob-harness-control/mcp/store.mjs +++ b/plugins/plumbob-harness-control/mcp/store.mjs @@ -1187,6 +1187,7 @@ export function openStore(file) { for (const [name, type] of Object.entries(COLUMN_TYPES)) { if (!columns.has(name)) database.exec(`ALTER TABLE jobs ADD COLUMN ${name} ${type}`); } + database.exec('CREATE INDEX IF NOT EXISTS jobs_lifecycle_state_idx ON jobs(lifecycle_state)'); database.exec(` CREATE TABLE IF NOT EXISTS job_events ( job_id TEXT NOT NULL, @@ -1369,6 +1370,23 @@ export function listStoredJobs(database, limit) { return database.prepare('SELECT * FROM jobs ORDER BY created_at DESC LIMIT ?').all(limit); } +/** + * Return every non-terminal job without applying the bounded recent-jobs + * presentation limit. Lifecycle state is authoritative for new rows while + * the legacy status fallback keeps older ledgers visible to the control plane. + */ +export function listActiveStoredJobs(database, limit = null) { + const query = ` + SELECT * FROM jobs + WHERE lifecycle_state IN ('accepted', 'started', 'working') + OR (lifecycle_state IS NULL AND status IN ('queued', 'starting', 'running', 'cancelling')) + ORDER BY created_at DESC${limit === null ? '' : ' LIMIT ?'} + `; + return limit === null + ? database.prepare(query).all() + : database.prepare(query).all(limit); +} + export function findStoredRequest(database, requestId) { return database.prepare('SELECT * FROM jobs WHERE request_id = ?').get(requestId) ?? null; } diff --git a/plugins/plumbob-harness-control/package.json b/plugins/plumbob-harness-control/package.json index 7abe97a..0b2bcf0 100644 --- a/plugins/plumbob-harness-control/package.json +++ b/plugins/plumbob-harness-control/package.json @@ -1,6 +1,6 @@ { "name": "plumbob-harness-control", - "version": "2.1.2", + "version": "2.2.0", "private": false, "description": "Codex-Co-Engineer: a bounded MCP control plane for DeepSeek Harness and Grok Build jobs.", "license": "MIT", @@ -11,6 +11,7 @@ "files": [ ".codex-plugin", ".mcp.json", + "LICENSE", "README.md", "assets", "bin", diff --git a/plugins/plumbob-harness-control/skills/control-plumbob-agents/SKILL.md b/plugins/plumbob-harness-control/skills/control-plumbob-agents/SKILL.md index fb59534..46509c6 100644 --- a/plugins/plumbob-harness-control/skills/control-plumbob-agents/SKILL.md +++ b/plugins/plumbob-harness-control/skills/control-plumbob-agents/SKILL.md @@ -7,19 +7,27 @@ description: Use Codex-Co-Engineer to control and monitor target-bound DeepSeek Use the plugin tools instead of shell commands for dispatch, monitoring, and cancellation. The stable MCP server identifier is `plumbob-harness-control`. +This skill ships with Co-Engineer `2.2.0`; the identifier is kept stable across +releases for existing Codex configurations. -1. Run `status` when adapter or control-plane state is unknown. +1. Run the Co-Engineer MCP `status` tool when adapter or control-plane state is unknown. 2. Complete the MCP Inspector preflight before dispatching work. 3. Validate one strict target contract for every dispatch. -4. Call `run` with a stable `request_id` and the caller's expected fingerprint. +4. For normal use, call the Co-Engineer MCP `run` tool with a stable + `request_id` and `target_binding: "control_plane"`; the connector computes + and binds the exact target identity. + Advanced callers may omit `target_binding` and provide their own + `expected_target_fingerprint` assertion. Select `kind: "grok_build"` for the official Grok CLI; use only the typed Grok fields in its schema, including bounded `json_schema` only with JSON output and `include_partial_messages` only with Messages-format streaming. The server passes OAuth/session state through Grok's normal user environment and does not accept xAI credentials. -5. Use `jobs` with `until: "terminal"` to monitor a long-running job. -6. Use `cancel` only after the user explicitly requests cancellation. -7. Use the explicit read-only `capacity` tool when provider capacity or usage +5. Use the Co-Engineer MCP `jobs` tool with `until: "terminal"` to monitor a + long-running job. +6. Use the Co-Engineer MCP `cancel` tool only after the user explicitly + requests cancellation. +7. Use the explicit read-only Co-Engineer MCP `capacity` tool when provider capacity or usage affects routing. Select `codex`, `grok`, and/or `dsh`; use `include_usage` for optional Codex usage, `grok_session_id` for exact Grok session usage, and `dsh_job_id` for an exact DSH receipt. @@ -81,8 +89,9 @@ private material. ## Capacity and provider boundaries -`status` remains a compact provider-free health check on its normal path. -`capacity` is the sole explicit provider-read surface. It uses the official +The Co-Engineer MCP `status` tool remains a compact provider-free health check +on its normal path. The Co-Engineer MCP `capacity` tool is the sole explicit +provider-read surface. It uses the official Codex App Server rate-limit/credit endpoints and Grok ACP billing/session usage methods. Results are compact and cached independently per provider and selector; the default 60-second cache can be bypassed with `refresh: true`. @@ -100,7 +109,7 @@ accepts credentials or asks for a per-call egress/authorization prompt. Grok Build coding dispatch uses its documented direct headless prompt interface. The ACP `grok agent stdio` interface is used only for the -read-only `capacity` billing and session-usage calls; it is not a coding +read-only Co-Engineer MCP `capacity` billing and session-usage calls; it is not a coding dispatch transport. Grok and DSH may use their own internal subagent tools. Record delegation as supported/requested/effective and keep `effective: unknown` unless runtime evidence proves it; do not present those @@ -110,7 +119,8 @@ peer workers. Do not call or advertise a public ACP session surface. The packaged ACPX, bounded-proxy, and Grok outer-runtime modules are gated experimental -conformance components and are not wired into `run`. Direct Grok dispatch still +conformance components and are not wired into the Co-Engineer MCP `run` tool. +Direct Grok dispatch still uses the official CLI-managed sandbox. The outer experiment supports an attested auth file, not `XAI_API_KEY`, and still requires real host/systemd acceptance. diff --git a/plugins/plumbob-harness-control/test/acp-event-ledger.test.mjs b/plugins/plumbob-harness-control/test/acp-event-ledger.test.mjs index 13eb6c1..8c5c3c7 100644 --- a/plugins/plumbob-harness-control/test/acp-event-ledger.test.mjs +++ b/plugins/plumbob-harness-control/test/acp-event-ledger.test.mjs @@ -205,7 +205,7 @@ test('rejects every free-form provider field including secrets, paths, raw JSON, { type: 'tool', status: 'running', tool_args: { dangerous: true } }, { type: 'control', status: 'running', jsonrpc: '2.0' }, event(1, { text: 'read /private/repository/secret.txt' }), - event(1, { text: 'ghp_abcdefghijklmnopqrstuvwxyz0123456789' }), + event(1, { text: 'not-a-real-credential-canary' }), event(1, { raw_json: '{"jsonrpc":"2.0","params":{"secret":true}}' }), event(1, { provider_id: 'grok-local-acp' }), ]; diff --git a/plugins/plumbob-harness-control/test/branding.test.mjs b/plugins/plumbob-harness-control/test/branding.test.mjs index 9abda9e..c3de02b 100644 --- a/plugins/plumbob-harness-control/test/branding.test.mjs +++ b/plugins/plumbob-harness-control/test/branding.test.mjs @@ -12,7 +12,7 @@ test('plugin presents the Co-Engineer brand with usable icon assets', async () = ); assert.equal(manifest.name, 'plumbob-harness-control'); - assert.equal(manifest.version, '2.1.2'); + assert.equal(manifest.version, '2.2.0'); assert.equal(manifest.interface.displayName, 'Codex-Co-Engineer'); assert.equal(manifest.interface.composerIcon, './assets/icon.svg'); assert.equal(manifest.interface.logo, './assets/co-engineer.png'); diff --git a/plugins/plumbob-harness-control/test/control.test.mjs b/plugins/plumbob-harness-control/test/control.test.mjs index e3d363c..9c3c839 100644 --- a/plugins/plumbob-harness-control/test/control.test.mjs +++ b/plugins/plumbob-harness-control/test/control.test.mjs @@ -203,6 +203,11 @@ test('status and jobs list expose bounded job summaries while jobs get keeps det configuration_digest: 'sha256:' + 'c'.repeat(64), }; const database = openStore(path.join(state, 'control.sqlite3')); + await writeFile(path.join(state, 'job.log'), [ + JSON.stringify({ type: 'reasoning', text: 'private chain of thought must not be returned' }), + JSON.stringify({ type: 'text', text: 'final actionable finding' }), + JSON.stringify({ type: 'result', result: { status: 'completed' } }), + ].join('\n') + '\n'); insertJob(database, { id: 'grok-build-compact-1234', kind: 'grok_build', @@ -273,6 +278,8 @@ test('status and jobs list expose bounded job summaries while jobs get keeps det const detailed = await dispatchControl('jobs', { action: 'get', job_id: 'grok-build-compact-1234', tail_lines: 0 }); assert.equal(detailed.job.effective_configuration.prompt, effectiveConfiguration.prompt); assert.equal(detailed.job.target_context.prompt_should_not_escape, targetContext.prompt_should_not_escape); + assert.equal(detailed.final_response.text, 'final actionable finding'); + assert.equal(detailed.final_response.source, 'grok_streaming_json'); assert.ok(Array.isArray(detailed.job.lifecycle)); }); @@ -343,6 +350,143 @@ test('agent locks apply only to overlapping execution scopes', async () => { ), false); }); +test('active workspace locking does not depend on the bounded recent-job list', async (context) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'plumbob-active-lock-query-test-')); + const state = path.join(directory, 'state'); + const target = path.join(directory, 'target'); + await mkdir(target); + assert.equal(spawnSync('git', ['init', '-q', target]).status, 0); + await writeFile(path.join(target, 'note.txt'), 'initial\n'); + assert.equal(spawnSync('git', ['-C', target, 'add', 'note.txt']).status, 0); + assert.equal(spawnSync('git', [ + '-C', target, + '-c', 'user.name=Co-Engineer Test', + '-c', 'user.email=co-engineer-test@example.invalid', + 'commit', '-qm', 'initial', + ]).status, 0); + const previousState = process.env.CODEX_CO_ENGINEER_STATE_DIR; + process.env.CODEX_CO_ENGINEER_STATE_DIR = state; + context.after(async () => { + if (previousState === undefined) delete process.env.CODEX_CO_ENGINEER_STATE_DIR; + else process.env.CODEX_CO_ENGINEER_STATE_DIR = previousState; + await rm(directory, { recursive: true, force: true }); + }); + + const { __testing, ToolError } = await import(`../mcp/control.mjs?active-lock-query=${Date.now()}`); + await mkdir(state, { mode: 0o700 }); + const database = openStore(path.join(state, 'control.sqlite3')); + const newest = Date.now(); + for (let index = 0; index < 120; index += 1) { + const createdAt = new Date(newest - index * 1000).toISOString(); + insertJob(database, { + id: `completed-recent-${index}`, + kind: 'deepseek_agent', + status: 'succeeded', + lifecycle_state: 'completed', + terminal_state: 'completed', + summary: 'recent terminal fixture', + created_at: createdAt, + updated_at: createdAt, + finished_at: createdAt, + log_file: path.join(state, `completed-${index}.log`), + cancel_file: path.join(state, `completed-${index}.cancel`), + }); + } + const activeCreatedAt = new Date(newest - 121000).toISOString(); + insertJob(database, { + id: 'active-old-agent', + kind: 'deepseek_agent', + status: 'running', + lifecycle_state: 'working', + summary: 'old active fixture', + created_at: activeCreatedAt, + updated_at: activeCreatedAt, + child_pid: process.pid, + log_file: path.join(state, 'active.log'), + cancel_file: path.join(state, 'active.cancel'), + target_context: JSON.stringify({ + working_directory: target, + expected_git_root: target, + git_common_directory: path.join(target, '.git'), + role: 'implement', + }), + effective_configuration: JSON.stringify({ working_directory: target }), + }); + database.close(); + + await assert.rejects( + __testing.startAgentJob({ + working_directory: target, + expected_git_root: target, + git_common_directory: path.join(target, '.git'), + }, () => 'must-not-start'), + (error) => error instanceof ToolError + && error.code === 'workspace_busy' + && /active-old-agent/.test(error.message), + ); + const active = await __testing.listActiveJobs(); + assert.deepEqual(active.map((job) => job.id), ['active-old-agent']); +}); + +test('cancellation persists before child ownership is proven and never signals a foreign PID', async (context) => { + const state = await mkdtemp(path.join(os.tmpdir(), 'plumbob-cancel-intent-test-')); + const previousState = process.env.CODEX_CO_ENGINEER_STATE_DIR; + process.env.CODEX_CO_ENGINEER_STATE_DIR = state; + context.after(async () => { + if (previousState === undefined) delete process.env.CODEX_CO_ENGINEER_STATE_DIR; + else process.env.CODEX_CO_ENGINEER_STATE_DIR = previousState; + await rm(state, { recursive: true, force: true }); + }); + + const { dispatchControl } = await import(`../mcp/control.mjs?cancel-intent=${Date.now()}`); + const database = openStore(path.join(state, 'control.sqlite3')); + const foreignCancelFile = path.join(state, 'foreign.cancel'); + insertJob(database, { + id: 'foreign-running-job', + kind: 'grok_build', + status: 'running', + lifecycle_state: 'working', + summary: 'foreign PID fixture', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + child_pid: process.pid, + log_file: path.join(state, 'foreign.log'), + cancel_file: foreignCancelFile, + }); + const acceptedCancelFile = path.join(state, 'accepted.cancel'); + insertJob(database, { + id: 'accepted-no-child-job', + kind: 'deepseek_agent', + status: 'queued', + lifecycle_state: 'accepted', + summary: 'accepted cancellation fixture', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + log_file: path.join(state, 'accepted.log'), + cancel_file: acceptedCancelFile, + }); + database.close(); + + const foreign = await dispatchControl('cancel', { job_id: 'foreign-running-job' }); + assert.equal(foreign.ok, true); + assert.equal(foreign.job.termination_reason, 'cancel_requested'); + assert.equal(foreign.job.signal_sent, null); + assert.equal(foreign.job.status, 'working'); + assert.match(await readFile(foreignCancelFile, 'utf8'), /T/); + + const accepted = await dispatchControl('cancel', { job_id: 'accepted-no-child-job' }); + assert.equal(accepted.ok, true); + assert.equal(accepted.job.terminal_state, 'cancelled'); + assert.match(await readFile(acceptedCancelFile, 'utf8'), /T/); +}); + +test('DSH web permission follows the target role', async () => { + const { __testing } = await import(`../mcp/control.mjs?dsh-web-permission=${Date.now()}`); + assert.equal(__testing.dshWebPermissionMode('review'), 'read-only'); + assert.equal(__testing.dshWebPermissionMode('verify'), 'read-only'); + assert.equal(__testing.dshWebPermissionMode('implement'), 'workspace-write'); +}); + test('omitted, null, and unknown target fields fail without default fallback', async () => { const { __testing, dispatchControl, ToolError } = await import(`../mcp/control.mjs?strict-target=${Date.now()}`); await assert.rejects( @@ -385,6 +529,70 @@ test('capacity dispatch validates bounded provider selectors before any provider ); }); +test('production Grok capacity uses the administrator-selected executable', async (context) => { + const previousCommand = process.env.CODEX_CO_ENGINEER_GROK_COMMAND; + // This path must not be silently replaced by the literal `grok` fallback. + process.env.CODEX_CO_ENGINEER_GROK_COMMAND = '/definitely/missing/codex-test-grok'; + context.after(() => { + if (previousCommand === undefined) delete process.env.CODEX_CO_ENGINEER_GROK_COMMAND; + else process.env.CODEX_CO_ENGINEER_GROK_COMMAND = previousCommand; + }); + const { dispatchControl } = await import(`../mcp/control.mjs?grok-capacity-command=${Date.now()}`); + const result = await dispatchControl('capacity', { + providers: ['grok'], + refresh: true, + max_age_seconds: 0, + }); + assert.equal(result.providers[0].status, 'unavailable'); + assert.deepEqual(result.providers[0].error, { code: 'capacity_query_failed' }); +}); + +test('production Grok capacity sends bounded RPC to the selected executable from safe cwd', async (context) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'codex-grok-capacity-selected-test-')); + const state = path.join(directory, 'state'); + const invocation = path.join(directory, 'invocation.jsonl'); + const fakeGrok = path.join(directory, 'selected-grok'); + const fakeProgram = [ + '#!/usr/bin/env node', + "const { appendFileSync } = require('node:fs');", + `appendFileSync(${JSON.stringify(invocation)}, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd() }) + '\\n');`, + "let buffer = '';", + 'function respond(request) {', + " const result = request.method === 'initialize' ? { protocolVersion: 1, authMethods: [] } : { config: { creditUsagePercent: 17 } };", + " process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\\n');", + '}', + "process.stdin.setEncoding('utf8');", + "process.stdin.on('data', (chunk) => { buffer += chunk; let newline; while ((newline = buffer.indexOf('\\n')) >= 0) { const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); if (line.trim()) respond(JSON.parse(line)); } });", + 'process.stdin.resume();', + 'const keepAlive = setInterval(() => {}, 1000);', + "process.stdin.on('end', () => clearInterval(keepAlive));", + ].join('\n'); + await writeFile(fakeGrok, fakeProgram, { mode: 0o700 }); + const previous = { + command: process.env.CODEX_CO_ENGINEER_GROK_COMMAND, + state: process.env.CODEX_CO_ENGINEER_STATE_DIR, + }; + process.env.CODEX_CO_ENGINEER_GROK_COMMAND = fakeGrok; + process.env.CODEX_CO_ENGINEER_STATE_DIR = state; + context.after(async () => { + if (previous.command === undefined) delete process.env.CODEX_CO_ENGINEER_GROK_COMMAND; + else process.env.CODEX_CO_ENGINEER_GROK_COMMAND = previous.command; + if (previous.state === undefined) delete process.env.CODEX_CO_ENGINEER_STATE_DIR; + else process.env.CODEX_CO_ENGINEER_STATE_DIR = previous.state; + await rm(directory, { recursive: true, force: true }); + }); + const { dispatchControl } = await import(`../mcp/control.mjs?grok-capacity-selected=${Date.now()}`); + const result = await dispatchControl('capacity', { + providers: ['grok'], + refresh: true, + max_age_seconds: 0, + }); + assert.equal(result.providers[0].status, 'available'); + assert.equal(result.providers[0].capacity.used_percent, 17); + const calls = (await readFile(invocation, 'utf8')).trim().split(/\r?\n/).map(JSON.parse); + assert.deepEqual(calls, [{ argv: ['agent', 'stdio'], cwd: '/' }]); +}); + test('preflight rejects a caller fingerprint mismatch before dispatch', async (context) => { const directory = await mkdtemp(path.join(os.tmpdir(), 'codex-co-engineer-fingerprint-test-')); const state = await mkdtemp(path.join(os.tmpdir(), 'codex-co-engineer-grok-profile-state-')); @@ -447,6 +655,205 @@ test('preflight rejects a caller fingerprint mismatch before dispatch', async (c ); }); +test('staging enforces one absolute deadline across Git and checkout scans', async (context) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'codex-co-engineer-stage-deadline-test-')); + context.after(() => rm(directory, { recursive: true, force: true })); + const { __testing, ToolError } = await import(`../mcp/control.mjs?stage-deadline=${Date.now()}`); + await assert.rejects( + __testing.runStageGit( + ['-c', 'alias.codex-deadline-wait=!sleep 5', 'codex-deadline-wait'], + 'bounded staging deadline test', + Date.now() + 100, + ), + (error) => error instanceof ToolError && error.code === 'target_stage_timeout', + ); + await assert.rejects( + __testing.assertStageSize(directory, Date.now() - 1), + (error) => error instanceof ToolError && error.code === 'target_stage_timeout', + ); +}); + +test('local annotated tags resolve and bind their peeled commit', async (context) => { + const repository = await mkdtemp(path.join(os.tmpdir(), 'codex-co-engineer-annotated-tag-test-')); + context.after(() => rm(repository, { recursive: true, force: true })); + assert.equal(spawnSync('git', ['init', '-q', repository]).status, 0); + await writeFile(path.join(repository, 'note.txt'), 'annotated\n'); + assert.equal(spawnSync('git', ['-C', repository, 'add', 'note.txt']).status, 0); + assert.equal(spawnSync('git', [ + '-C', repository, + '-c', 'user.name=Codex-Co-Engineer Test', + '-c', 'user.email=codex-co-engineer@example.invalid', + 'commit', '-qm', 'annotated', + ]).status, 0); + const head = spawnSync('git', ['-C', repository, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).stdout.trim(); + assert.equal(spawnSync('git', [ + '-C', repository, + '-c', 'user.name=Codex-Co-Engineer Test', + '-c', 'user.email=codex-co-engineer@example.invalid', + 'tag', '-a', 'annotated-release', '-m', 'annotated release', + ]).status, 0); + const { __testing } = await import(`../mcp/control.mjs?annotated-ref=${Date.now()}`); + const resolved = await __testing.resolveLocalRef( + repository, + 'refs/tags/annotated-release', + head, + Date.now() + 5000, + ); + assert.deepEqual(resolved, { ref: 'refs/tags/annotated-release', commit: head }); + const remoteResolved = await __testing.resolveGithubRef( + repository, + 'refs/tags/annotated-release', + Date.now() + 5000, + ); + assert.deepEqual(remoteResolved, { ref: 'refs/tags/annotated-release', commit: head }); +}); + +test('control-plane binding computes the fingerprint and stages a clean local source outside its original path', async (context) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'codex-co-engineer-staging-test-')); + // Keep the source outside /tmp so the provider-writable-root policy can + // prove that only the staged copy is exposed when state itself is temporary. + const source = path.join(process.cwd(), `.codex-co-engineer-staging-source-${path.basename(directory)}`); + // Use an owner-controlled, non-provider-writable host location. The staged + // workflow intentionally rejects state beneath broad writable roots such as + // /tmp and /mnt/d, and the fixture must remain portable across CI users. + const state = path.join( + os.homedir(), + '.local', + 'state', + `codex-co-engineer-staging-state-${path.basename(directory)}`, + ); + await mkdir(source); + assert.equal(spawnSync('git', ['init', '-q', source]).status, 0); + await writeFile(path.join(source, 'note.txt'), 'initial\n'); + assert.equal(spawnSync('git', ['-C', source, 'add', 'note.txt']).status, 0); + assert.equal(spawnSync('git', [ + '-C', source, + '-c', 'user.name=Codex-Co-Engineer Test', + '-c', 'user.email=codex-co-engineer@example.invalid', + 'commit', '-qm', 'initial', + ]).status, 0); + const previous = { + state: process.env.CODEX_CO_ENGINEER_STATE_DIR, + roots: process.env.CODEX_CO_ENGINEER_ALLOWED_ROOTS, + }; + process.env.CODEX_CO_ENGINEER_STATE_DIR = state; + delete process.env.CODEX_CO_ENGINEER_ALLOWED_ROOTS; + context.after(async () => { + if (previous.state === undefined) delete process.env.CODEX_CO_ENGINEER_STATE_DIR; + else process.env.CODEX_CO_ENGINEER_STATE_DIR = previous.state; + if (previous.roots === undefined) delete process.env.CODEX_CO_ENGINEER_ALLOWED_ROOTS; + else process.env.CODEX_CO_ENGINEER_ALLOWED_ROOTS = previous.roots; + await rm(source, { recursive: true, force: true }); + await rm(directory, { recursive: true, force: true }); + await rm(state, { recursive: true, force: true }); + }); + + const { __testing, dispatchControl, ToolError } = await import(`../mcp/control.mjs?staging=${Date.now()}`); + const staged = await dispatchControl('preflight', { + schema_version: 'codex-co-engineer.config.v1', + kind: 'grok_build', + target_binding: 'control_plane', + target_context: { + schema_version: 'codex-co-engineer.target.v1', + mode: 'staged', + source: { type: 'local', path: source }, + role: 'review', + }, + }); + assert.equal(staged.target_binding, 'control_plane'); + assert.equal(staged.expected_target_fingerprint, staged.target_fingerprint); + assert.notEqual(staged.resolved_workspace, source); + assert.match(staged.resolved_workspace, /\/targets\/lease-[0-9a-f]{64}\/checkout$/); + assert.equal(staged.configuration.target_context.target_origin, 'control_plane_staged'); + assert.equal(await stat(staged.resolved_workspace).then((value) => value.isDirectory()), true); + const leasePath = path.join(path.dirname(staged.resolved_workspace), 'lease.json'); + const lease = JSON.parse(await readFile(leasePath, 'utf8')); + assert.equal(lease.schema_version, 'codex-co-engineer.target-lease.v1'); + assert.equal(lease.resolved_head, staged.configuration.target_context.expected_head); + + const stagedAgain = await dispatchControl('preflight', { + schema_version: 'codex-co-engineer.config.v1', + kind: 'grok_build', + target_binding: 'control_plane', + target_context: { + schema_version: 'codex-co-engineer.target.v1', + mode: 'staged', + source: { type: 'local', path: source }, + role: 'review', + }, + }); + assert.equal(stagedAgain.resolved_workspace, staged.resolved_workspace); + assert.equal(stagedAgain.target_fingerprint, staged.target_fingerprint); + + // Expired inactive leases are reclaimed before the next staging request; + // the deterministic descriptor still gives the caller the same checkout. + await writeFile(leasePath, JSON.stringify({ + ...lease, + last_used_at: new Date(Date.now() - (2 * 24 * 60 * 60 * 1000)).toISOString(), + })); + const stagedAfterExpiry = await dispatchControl('preflight', { + schema_version: 'codex-co-engineer.config.v1', + kind: 'grok_build', + target_binding: 'control_plane', + target_context: { + schema_version: 'codex-co-engineer.target.v1', + mode: 'staged', + source: { type: 'local', path: source }, + role: 'review', + }, + }); + assert.equal(stagedAfterExpiry.resolved_workspace, staged.resolved_workspace); + + const explicit = await dispatchControl('preflight', { + schema_version: 'codex-co-engineer.config.v1', + target_binding: 'control_plane', + target_context: { + schema_version: 'codex-co-engineer.target.v1', + mode: 'explicit', + working_directory: source, + expected_git_root: source, + expected_head: spawnSync('git', ['-C', source, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).stdout.trim(), + allowed_paths: ['.'], + role: 'review', + }, + }); + assert.equal(explicit.target_binding, 'control_plane'); + assert.equal(explicit.target_fingerprint, explicit.expected_target_fingerprint); + const sourceHead = spawnSync('git', ['-C', source, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).stdout.trim(); + const reviewIdentity = await __testing.prepareTarget({ + schema_version: 'codex-co-engineer.target.v1', + mode: 'explicit', + working_directory: source, + expected_git_root: source, + expected_head: sourceHead, + allowed_paths: ['.'], + role: 'review', + }); + const verifyIdentity = await __testing.prepareTarget({ + schema_version: 'codex-co-engineer.target.v1', + mode: 'explicit', + working_directory: source, + expected_git_root: source, + expected_head: sourceHead, + allowed_paths: ['note.txt'], + role: 'verify', + }); + assert.notEqual(reviewIdentity.targetFingerprint, verifyIdentity.targetFingerprint); + await assert.rejects( + dispatchControl('preflight', { + schema_version: 'codex-co-engineer.config.v1', + target_binding: 'control_plane', + target_context: { + schema_version: 'codex-co-engineer.target.v1', + mode: 'staged', + source: { type: 'github', repository: 'https://github.com/example/repo?token=bad' }, + role: 'review', + }, + }), + (error) => error instanceof ToolError && error.code === 'invalid_target_source', + ); +}); + test('managed DSH options flow through preflight, run configuration, and child environment', async (context) => { const directory = await mkdtemp(path.join(os.tmpdir(), 'codex-dsh-control-test-')); const state = path.join(directory, 'state'); diff --git a/plugins/plumbob-harness-control/test/grok-build.test.mjs b/plugins/plumbob-harness-control/test/grok-build.test.mjs index bfbdf7d..fb916c0 100644 --- a/plugins/plumbob-harness-control/test/grok-build.test.mjs +++ b/plugins/plumbob-harness-control/test/grok-build.test.mjs @@ -5,6 +5,7 @@ import path from 'node:path'; import test from 'node:test'; import { buildGrokArgs, + grokBuildFinalResponse, grokCapabilityProfile, grokBuildFailure, grokVersionProbe, @@ -383,6 +384,24 @@ test('Grok streaming parser classifies the final session outcome after blocked t assert.equal(grokBuildFailure(terminalFailure), 'session failed'); }); +test('Grok streaming parser exposes only a bounded final assistant response', () => { + const response = grokBuildFinalResponse([ + '{"type":"reasoning","text":"private chain of thought"}', + '{"type":"tool_call","toolName":"Read","status":"completed"}', + '{"type":"text","data":"Review "}', + '{"type":"message","message":{"role":"assistant","content":"complete."}}', + '{"type":"end","stopReason":"end_turn"}', + ].join('\n')); + assert.deepEqual(response, { + text: 'Review complete.', + truncated: false, + characters: 16, + }); + assert.equal(grokBuildFinalResponse('{"type":"reasoning","text":"not a report"}'), null); + const bounded = grokBuildFinalResponse('{"type":"result","result":"abcdefghijkl"}', 5); + assert.deepEqual(bounded, { text: 'abcd…', truncated: true, characters: 12 }); +}); + test('Grok streaming parser fails closed on sandbox fallback warnings', () => { assert.match( grokBuildFailure('warning: sandbox could not be applied; continuing without enforcement\n'), diff --git a/plugins/plumbob-harness-control/test/grok-outer-sandbox.test.mjs b/plugins/plumbob-harness-control/test/grok-outer-sandbox.test.mjs index 94e005c..96daad4 100644 --- a/plugins/plumbob-harness-control/test/grok-outer-sandbox.test.mjs +++ b/plugins/plumbob-harness-control/test/grok-outer-sandbox.test.mjs @@ -209,6 +209,8 @@ async function makeTargetContract({ root, working, common }, { resolved_cwd: working, git_common_directory: common, git_head: expectedHead, + allowed_paths: allowedPaths, + role, workspace_identity: workspaceIdentity, cwd_identity: cwdIdentity, }); @@ -836,9 +838,17 @@ test('strict real boundary pins provenance, exposes only the minroot closure, co (error) => error instanceof GrokOuterSandboxError && error.code === 'invalid_prepared_state', ); + const roleMismatchTarget = await makeTargetContract({ + root: tree.root, + working: tree.working, + common: tree.common, + }, { + expectedHead: tree.target.expected_head, + role: 'verify', + }); const roleMismatchPrepared = await prepareGrokOuterSandbox({ ...(await makeRealOptions(tree, bwrap, 'role-mismatch', 10_000, tree.providerPath, busybox)), - target: { ...tree.target, role: 'verify' }, + target: roleMismatchTarget, }); assert.notEqual(roleMismatchPrepared.target.target_contract_digest, prepared.target.target_contract_digest, 'role did not change the canonical target contract digest'); diff --git a/plugins/plumbob-harness-control/test/runner.test.mjs b/plugins/plumbob-harness-control/test/runner.test.mjs index 031fae1..8ad99ea 100644 --- a/plugins/plumbob-harness-control/test/runner.test.mjs +++ b/plugins/plumbob-harness-control/test/runner.test.mjs @@ -111,6 +111,8 @@ async function runTargetFixture(context, { resolved_cwd: cwd, git_common_directory: target.expectedCommon, git_head: target.expectedHead, + allowed_paths: allowedPaths, + role, workspace_identity: target.workspaceIdentity, cwd_identity: cwdIdentity, }), @@ -313,13 +315,14 @@ test('detached runner fixes the deadline at acceptance before child startup', as assert.equal(typeof job.elapsed_seconds, 'number'); }); -test('detached runner keeps an exit-code-0 cancellation unambiguously cancelled', async (context) => { +test('detached runner records pre-spawn cancellation without launching the provider', async (context) => { const directory = await mkdtemp(path.join(os.tmpdir(), 'plumbob-cancel-runner-test-')); context.after(async () => rm(directory, { recursive: true, force: true })); const databaseFile = path.join(directory, 'control.sqlite3'); const logFile = path.join(directory, 'job.log'); const cancelFile = path.join(directory, 'job.cancel'); + const providerStartedFile = path.join(directory, 'provider-started.marker'); const specFile = path.join(directory, 'job.spec.json'); const createdAt = new Date().toISOString(); const database = openStore(databaseFile); @@ -340,16 +343,19 @@ test('detached runner keeps an exit-code-0 cancellation unambiguously cancelled' log_file: logFile, cancel_file: cancelFile, command: process.execPath, - args: ['-e', 'setTimeout(() => process.exit(0), 150)'], + args: ['-e', `require('node:fs').writeFileSync(${JSON.stringify(providerStartedFile)}, 'started\\n'); setTimeout(() => process.exit(0), 150)`], env: {}, cwd: directory, timeout_seconds: 60, })); + // A durable cancellation marker present before runner startup must stop at + // the launch barrier. This is intentionally stronger than racing the + // marker against spawn: the provider must never be started for this job. + await writeFile(cancelFile, `${new Date().toISOString()}\n`); const runner = spawn(process.execPath, ['--no-warnings', path.join(ROOT, 'mcp', 'runner.mjs'), specFile], { stdio: 'ignore', }); - await writeFile(cancelFile, `${new Date().toISOString()}\n`); const exitCode = await new Promise((resolve) => runner.once('exit', resolve)); const completedStore = openStore(databaseFile); @@ -357,9 +363,11 @@ test('detached runner keeps an exit-code-0 cancellation unambiguously cancelled' completedStore.close(); assert.equal(exitCode, 0); assert.equal(job.status, 'cancelled'); - assert.equal(job.exit_code, 0); + assert.equal(job.exit_code, null); assert.equal(job.termination_reason, 'cancelled_by_user'); - assert.equal(job.signal_sent, 'SIGTERM'); + assert.equal(job.signal_sent, null); + assert.equal(job.child_pid, null); + await assert.rejects(stat(providerStartedFile), { code: 'ENOENT' }); assert.match(job.error, /Cancellation was requested/); }); diff --git a/plugins/plumbob-harness-control/test/secrets.test.mjs b/plugins/plumbob-harness-control/test/secrets.test.mjs new file mode 100644 index 0000000..88c7654 --- /dev/null +++ b/plugins/plumbob-harness-control/test/secrets.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { + chmod, + link, + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { loadModelApiKey } from '../mcp/secrets.mjs'; + +test('loads only a current-user owner-only regular key file through an O_NOFOLLOW descriptor', async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'codex-model-key-')); + context.after(() => rm(root, { recursive: true, force: true })); + const keyFile = path.join(root, 'model-api-key'); + await writeFile(keyFile, 'fixture-key-without-output\n', { mode: 0o600 }); + + const previous = process.env.MODEL_API_KEY; + delete process.env.MODEL_API_KEY; + try { + assert.deepEqual(await loadModelApiKey(keyFile), { available: true, source: 'protected_file' }); + assert.equal(typeof process.env.MODEL_API_KEY, 'string'); + assert.ok(process.env.MODEL_API_KEY.length > 0); + } finally { + if (previous === undefined) delete process.env.MODEL_API_KEY; + else process.env.MODEL_API_KEY = previous; + } +}); + +test('rejects symlinked, multiply-linked, and group/world-readable key files', async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'codex-model-key-')); + context.after(() => rm(root, { recursive: true, force: true })); + const keyFile = path.join(root, 'model-api-key'); + const target = path.join(root, 'target-key'); + const hardlink = path.join(root, 'hardlink-key'); + const realDirectory = path.join(root, 'real-directory'); + const linkedDirectory = path.join(root, 'linked-directory'); + await writeFile(target, 'fixture-key\n', { mode: 0o600 }); + await symlink(target, keyFile); + delete process.env.MODEL_API_KEY; + assert.deepEqual(await loadModelApiKey(keyFile), { available: false, source: null }); + + await rm(keyFile); + await mkdir(realDirectory, { mode: 0o700 }); + await writeFile(path.join(realDirectory, 'model-api-key'), 'fixture-key\n', { mode: 0o600 }); + await symlink(realDirectory, linkedDirectory); + assert.deepEqual( + await loadModelApiKey(path.join(linkedDirectory, 'model-api-key')), + { available: false, source: null }, + ); + + await link(target, hardlink); + assert.deepEqual(await loadModelApiKey(target), { available: false, source: null }); + + await rm(hardlink); + await chmod(target, 0o640); + assert.deepEqual(await loadModelApiKey(target), { available: false, source: null }); +}); + +test('does not derive a relative credential path when HOME is empty or absent', async (context) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'codex-model-key-')); + context.after(() => rm(root, { recursive: true, force: true })); + const cwdFallback = path.join(root, '.config', 'codex-co-engineer'); + await mkdir(cwdFallback, { recursive: true, mode: 0o700 }); + await writeFile(path.join(cwdFallback, 'model-api-key'), 'fixture-key\n', { mode: 0o600 }); + + const savedHome = process.env.HOME; + const savedXdg = process.env.XDG_CONFIG_HOME; + const savedKey = process.env.MODEL_API_KEY; + delete process.env.HOME; + delete process.env.XDG_CONFIG_HOME; + delete process.env.MODEL_API_KEY; + try { + const moduleUrl = `${new URL('../mcp/secrets.mjs', import.meta.url).href}?empty-home=${Date.now()}`; + const secrets = await import(moduleUrl); + assert.equal(secrets.MODEL_API_KEY_FILE, '/dev/null'); + assert.deepEqual(await secrets.loadModelApiKey(), { available: false, source: null }); + } finally { + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = savedXdg; + if (savedKey === undefined) delete process.env.MODEL_API_KEY; + else process.env.MODEL_API_KEY = savedKey; + } +}); diff --git a/plugins/plumbob-harness-control/test/server.test.mjs b/plugins/plumbob-harness-control/test/server.test.mjs index ec49b13..f2ad4ac 100644 --- a/plugins/plumbob-harness-control/test/server.test.mjs +++ b/plugins/plumbob-harness-control/test/server.test.mjs @@ -74,6 +74,8 @@ async function targetFixture(directory) { resolved_cwd: workspace, git_common_directory: common, git_head: head, + allowed_paths: ['.'], + role: 'review', workspace_identity: { device: String(identity.dev), inode: String(identity.ino) }, cwd_identity: { device: String(identity.dev), inode: String(identity.ino) }, }); @@ -171,7 +173,7 @@ test('MCP handshake exposes strict preflight identity and guarded status', async result.stdout.trim().split(/\r?\n/).map((line) => JSON.parse(line)).map((message) => [message.id, message]), ); assert.equal(responses.get(1).result.serverInfo.name, 'plumbob-harness-control'); - assert.equal(responses.get(1).result.serverInfo.version, '2.1.2'); + assert.equal(responses.get(1).result.serverInfo.version, '2.2.0'); assert.equal(responses.get(1).result.protocolVersion, '2025-11-25'); assert.deepEqual( responses.get(2).result.tools.map((tool) => tool.name), @@ -198,7 +200,7 @@ test('MCP handshake exposes strict preflight identity and guarded status', async assert.equal(statusBody.ok, true); assert.equal(statusBody.integration, 'control-only'); assert.equal(statusBody.control_plane.health, 'healthy'); - assert.equal(statusBody.control_plane.version, '2.1.2'); + assert.equal(statusBody.control_plane.version, '2.2.0'); assert.ok(['administrator-allowlisted', 'explicit-target-any-git-root'].includes(statusBody.targeting.mode)); assert.equal(statusBody.targeting.implement_targets, 'explicit-scoped-workspace'); assert.equal(statusBody.ui.optional, true); @@ -232,7 +234,7 @@ test('MCP handshake exposes strict preflight identity and guarded status', async assert.match(preflight.configuration_digest, /^[0-9a-f]{64}$/); assert.equal(preflight.transport, 'stdio'); assert.equal(preflight.protocol_version, '2025-11-25'); - assert.deepEqual(preflight.server_identity, { name: 'plumbob-harness-control', version: '2.1.2' }); + assert.deepEqual(preflight.server_identity, { name: 'plumbob-harness-control', version: '2.2.0' }); assert.deepEqual(preflight.available_tools, ['preflight', 'status', 'capacity', 'runtime', 'run', 'jobs', 'cancel']); assert.equal(preflight.toolset_digest, toolSetDigest(responses.get(2).result.tools)); diff --git a/plugins/plumbob-harness-control/test/state.test.mjs b/plugins/plumbob-harness-control/test/state.test.mjs index d1538b5..ba55acf 100644 --- a/plugins/plumbob-harness-control/test/state.test.mjs +++ b/plugins/plumbob-harness-control/test/state.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; import { chmod, link, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import net from 'node:net'; import os from 'node:os'; @@ -22,6 +23,18 @@ import { modelApiKeyBindingDigest } from '../mcp/secrets.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +async function daemonProcessMatches(record) { + try { + const statText = await readFile(`/proc/${record.pid}/stat`, 'utf8'); + const closingParenthesis = statText.lastIndexOf(')'); + if (closingParenthesis < 0) return false; + const fields = statText.slice(closingParenthesis + 2).trim().split(/\s+/u); + return fields[0] !== 'Z' && fields[19] === record.start_time; + } catch { + return false; + } +} + function socketRpc(socketFile, message) { return new Promise((resolve, reject) => { const socket = net.createConnection(socketFile); @@ -48,6 +61,19 @@ function socketRpc(socketFile, message) { }); } +async function waitForSocket(socketFile, timeoutMilliseconds = 3000) { + const deadline = Date.now() + timeoutMilliseconds; + while (Date.now() < deadline) { + try { + if ((await lstat(socketFile)).isSocket()) return; + } catch { + // The daemon may still be creating its state directory and socket. + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Timed out waiting for daemon socket: ${socketFile}`); +} + async function verifiedShutdown(socketFile, prepared) { const [socketMetadata, daemonRecord] = await Promise.all([ lstat(socketFile), @@ -393,6 +419,59 @@ test('server and daemon use the same XDG_STATE_HOME path when no explicit root i await verifiedShutdown(socketFile, prepared); }); +test('daemon treats a malformed idle timeout as the safe default instead of draining immediately', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'codex-daemon-idle-config-')); + const state = path.join(root, 'state'); + const socketFile = path.join(state, 'control.sock'); + const inherited = Object.fromEntries(Object.entries(process.env).filter(([name]) => ![ + 'CODEX_CO_ENGINEER_STATE_DIR', + 'PLUMBOB_HARNESS_STATE_DIR', + 'CODEX_TASK_STATE_ROOT', + 'XDG_STATE_HOME', + 'CODEX_CO_ENGINEER_DAEMON_IDLE_SECONDS', + 'PLUMBOB_HARNESS_DAEMON_IDLE_SECONDS', + 'CODEX_CO_ENGINEER_MODEL_API_KEY_FILE', + 'PLUMBOB_HARNESS_MODEL_API_KEY_FILE', + ].includes(name))); + const daemon = spawn(process.execPath, [path.join(ROOT, 'mcp', 'daemon.mjs')], { + cwd: ROOT, + env: { + ...inherited, + MODEL_API_KEY: '', + CODEX_CO_ENGINEER_STATE_DIR: state, + CODEX_CO_ENGINEER_DAEMON_IDLE_SECONDS: 'not-a-duration', + CODEX_CO_ENGINEER_MODEL_API_KEY_FILE: path.join(root, 'missing-model-key'), + }, + stdio: 'ignore', + }); + + try { + await waitForSocket(socketFile); + const daemonRecord = JSON.parse(await readFile(path.join(state, 'daemon.pid'), 'utf8')); + await new Promise((resolve) => setTimeout(resolve, 250)); + assert.equal(await daemonProcessMatches(daemonRecord), true); + } finally { + if (daemon.exitCode === null) { + try { + const prepared = await prepareStateDirectory(state); + if ((await lstat(socketFile)).isSocket()) await verifiedShutdown(socketFile, prepared); + } catch { + daemon.kill('SIGTERM'); + } + } + if (daemon.exitCode === null) { + await Promise.race([ + once(daemon, 'exit'), + new Promise((resolve) => setTimeout(() => { + daemon.kill('SIGKILL'); + resolve(); + }, 2000)), + ]); + } + await rm(root, { recursive: true, force: true }); + } +}); + test('server refuses a symlinked SQLite ledger without touching its target', async (context) => { const root = await mkdtemp(path.join(os.tmpdir(), 'codex-state-db-symlink-')); context.after(() => rm(root, { recursive: true, force: true })); diff --git a/scripts/inspector-preflight.mjs b/scripts/inspector-preflight.mjs index a27b04a..1b63398 100755 --- a/scripts/inspector-preflight.mjs +++ b/scripts/inspector-preflight.mjs @@ -80,6 +80,7 @@ try { const fingerprint = targetIdentityDigest({ mode: 'explicit', resolved_workspace: workspace, resolved_cwd: workspace, git_common_directory: common, git_head: head, + allowed_paths: ['.'], role: 'review', workspace_identity: { device: String(identity.dev), inode: String(identity.ino) }, cwd_identity: { device: String(identity.dev), inode: String(identity.ino) }, }); @@ -159,16 +160,25 @@ try { assert.ok(structured.available_tools.includes('capacity')); const grokImplementTarget = { ...args.target_context, role: 'implement' }; + const grokImplementFingerprint = targetIdentityDigest({ + mode: 'explicit', resolved_workspace: workspace, resolved_cwd: workspace, + git_common_directory: common, git_head: head, + allowed_paths: ['.'], role: 'implement', + workspace_identity: { device: String(identity.dev), inode: String(identity.ino) }, + cwd_identity: { device: String(identity.dev), inode: String(identity.ino) }, + }); const grokImplementOmitted = structuredResult(inspect('tools/call', 'preflight', { ...args, kind: 'grok_build', target_context: grokImplementTarget, + expected_target_fingerprint: grokImplementFingerprint, })); assert.notEqual(grokImplementOmitted.code, 'invalid_argument'); const grokImplementAuto = structuredResult(inspect('tools/call', 'preflight', { ...args, kind: 'grok_build', target_context: grokImplementTarget, + expected_target_fingerprint: grokImplementFingerprint, permission_mode: 'auto', agent: 'project-review', delegation: { enabled: false }, diff --git a/scripts/plugin-activation-fixture.mjs b/scripts/plugin-activation-fixture.mjs index dda2049..5ec253e 100644 --- a/scripts/plugin-activation-fixture.mjs +++ b/scripts/plugin-activation-fixture.mjs @@ -29,8 +29,14 @@ import { } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; const PLUGIN = 'fixture-plugin'; +const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const RELEASE_MANIFESTS = Object.freeze([ + 'plugins/plumbob-harness-control/.codex-plugin/plugin.json', + 'plugins/cursor-cloud-control/.codex-plugin/plugin.json', +]); const VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; const METADATA_FILE = '.codex-version.json'; const NEXT_MARKER = '.next-'; @@ -65,6 +71,20 @@ async function readJson(file) { return JSON.parse(await readFile(file, 'utf8')); } +async function currentReleaseVersions() { + const versions = {}; + for (const relative of RELEASE_MANIFESTS) { + const manifest = await readJson(path.join(REPOSITORY_ROOT, relative)); + assert.equal(typeof manifest.name, 'string', `${relative} must declare a plugin name`); + assert.match(manifest.name, VERSION_PATTERN, `${relative} declares an unsafe plugin name`); + assert.equal(typeof manifest.version, 'string', `${relative} must declare a plugin version`); + assert.match(manifest.version, VERSION_PATTERN, `${relative} declares an unsafe plugin version`); + assert.equal(versions[manifest.name], undefined, `duplicate release plugin ${manifest.name}`); + versions[manifest.name] = manifest.version; + } + return versions; +} + function digestFiles(files) { const hash = createHash('sha256'); for (const [name, contents] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) { @@ -385,6 +405,19 @@ async function runFixture() { const store = new ActivationFixture(tempRoot); await store.init(); try { + const releaseVersions = await currentReleaseVersions(); + for (const [plugin, version] of Object.entries(releaseVersions)) { + await store.stage(plugin, version, { + 'release/VERSION': `${plugin}@${version}\n`, + }); + await store.activate(plugin, version); + assert.equal(await store.activeVersion(plugin), version); + await store.lease(`release-${plugin}`, plugin, version); + assert.equal((await readJson(store.leaseFile(`release-${plugin}`))).version, version); + await store.releaseLease(`release-${plugin}`); + assert.deepEqual((await store.gc(plugin)).retained, [version]); + } + const v1 = await store.stage(PLUGIN, '1.0.0', { 'skill/SKILL.md': 'version one\n' }); const v2 = await store.stage(PLUGIN, '2.0.0', { 'skill/SKILL.md': 'version two\n' }); const v3 = await store.stage(PLUGIN, '3.0.0', { 'skill/SKILL.md': 'version three\n' }); diff --git a/scripts/validate-release.mjs b/scripts/validate-release.mjs index 6ba2bf6..bf5e395 100755 --- a/scripts/validate-release.mjs +++ b/scripts/validate-release.mjs @@ -13,16 +13,25 @@ import { } from '../plugins/cursor-cloud-control/mcp/local.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const EXPECTED_CO_ENGINEER_VERSION = '2.2.0'; +const EXPECTED_CURSOR_VERSION = '0.4.0'; +const EXPECTED_CURSOR_LOCAL_WIRE_VERSION = '0.2.0'; +const EXPECTED_MCP_PROTOCOL_VERSION = '2025-11-25'; +const EXPECTED_CO_ENGINEER_TOOLS = ['preflight', 'status', 'capacity', 'runtime', 'run', 'jobs', 'cancel']; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/iu; const required = [ - '.codex/release-gate.toml', '.github/workflows/ci.yml', '.gitignore', + '.agents/plugins/marketplace.json', '.codex/release-gate.toml', + '.github/workflows/ci.yml', '.gitignore', 'CHANGELOG.md', 'CONTRIBUTING.md', 'LICENSE', 'README.md', 'SECURITY.md', 'config/configuration.example.json', 'config/configuration.schema.json', + 'examples/preflight-result.json', 'examples/target-context.json', 'docs/acp-and-orchestrator-adoption.md', 'docs/configuration.md', 'docs/control-plane-reliability-plan.md', 'docs/data-handling.md', 'docs/preflight-inspector.md', 'docs/provider-capability-map.md', 'docs/release.md', 'docs/target-contract.md', 'plugins/plumbob-harness-control/.codex-plugin/plugin.json', 'plugins/plumbob-harness-control/.mcp.json', + 'plugins/plumbob-harness-control/LICENSE', 'plugins/plumbob-harness-control/package.json', 'plugins/plumbob-harness-control/README.md', 'plugins/plumbob-harness-control/skills/control-plumbob-agents/SKILL.md', @@ -51,6 +60,7 @@ const required = [ 'plugins/plumbob-harness-control/assets/dsh-headless-usage-runner.mjs', 'plugins/plumbob-harness-control/test/acp-bounded-proxy.test.mjs', 'plugins/plumbob-harness-control/test/acp-event-ledger.test.mjs', + 'plugins/plumbob-harness-control/test/secrets.test.mjs', 'plugins/plumbob-harness-control/test/acp-provider-registry.test.mjs', 'plugins/plumbob-harness-control/test/acp-resource-boundary.test.mjs', 'plugins/plumbob-harness-control/test/acp-session-schema.test.mjs', @@ -72,6 +82,7 @@ const required = [ 'plugins/plumbob-harness-control/test/server.test.mjs', 'plugins/cursor-cloud-control/.codex-plugin/plugin.json', 'plugins/cursor-cloud-control/.mcp.json', + 'plugins/cursor-cloud-control/LICENSE', 'plugins/cursor-cloud-control/package.json', 'plugins/cursor-cloud-control/README.md', 'plugins/cursor-cloud-control/mcp/server.mjs', @@ -133,25 +144,52 @@ const packageJson = await json('plugins/plumbob-harness-control/package.json'); const cursorManifest = await json('plugins/cursor-cloud-control/.codex-plugin/plugin.json'); const cursorPackage = await json('plugins/cursor-cloud-control/package.json'); const cursorMcp = await json('plugins/cursor-cloud-control/.mcp.json'); +const marketplace = await json('.agents/plugins/marketplace.json'); const vendorPackage = await json('tools/acpx-vendor/package.json'); const lockBytes = await readFile(path.join(ROOT, 'tools/acpx-vendor/package-lock.json')); const lock = JSON.parse(lockBytes); const runtimeManifest = await json('plugins/plumbob-harness-control/assets/acpx-runtime.manifest.json'); const configurationSchema = await json('config/configuration.schema.json'); const configurationExample = await json('config/configuration.example.json'); +const examplePreflightResult = await json('examples/preflight-result.json'); +const exampleTargetContext = await json('examples/target-context.json'); -if (manifest.version !== '2.1.2' || packageJson.version !== '2.1.2' - || SERVER_IDENTITY.version !== '2.1.2' +if (manifest.version !== EXPECTED_CO_ENGINEER_VERSION || packageJson.version !== EXPECTED_CO_ENGINEER_VERSION + || SERVER_IDENTITY.version !== EXPECTED_CO_ENGINEER_VERSION || manifest.version !== packageJson.version) { - fail('Co-Engineer manifest, package, and MCP server versions must remain pinned at 2.1.2.'); + fail(`Co-Engineer manifest, package, and MCP server versions must remain pinned at ${EXPECTED_CO_ENGINEER_VERSION}.`); } if (manifest.interface.displayName !== 'Codex-Co-Engineer') fail('Public display name mismatch.'); -if (cursorManifest.version !== '0.3.0' || cursorPackage.version !== '0.3.0' - || CURSOR_SERVER_IDENTITY.version !== '0.3.0' - || CURSOR_LOCAL_SERVER_IDENTITY.version !== '0.1.0' +const marketplaceEntries = marketplace.plugins?.map((entry) => ({ + name: entry.name, + source: entry.source, + policy: entry.policy, + category: entry.category, +})); +if (marketplace.name !== 'codex-co-engineer' + || marketplace.interface?.displayName !== 'Codex-Co-Engineer' + || JSON.stringify(marketplaceEntries) !== JSON.stringify([ + { + name: 'plumbob-harness-control', + source: { source: 'local', path: './plugins/plumbob-harness-control' }, + policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' }, + category: 'Developer Tools', + }, + { + name: 'cursor-cloud-control', + source: { source: 'local', path: './plugins/cursor-cloud-control' }, + policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' }, + category: 'Developer Tools', + }, + ])) { + fail('Public marketplace must expose the two release plugin packages with local sources.'); +} +if (cursorManifest.version !== EXPECTED_CURSOR_VERSION || cursorPackage.version !== EXPECTED_CURSOR_VERSION + || CURSOR_SERVER_IDENTITY.version !== EXPECTED_CURSOR_VERSION + || CURSOR_LOCAL_SERVER_IDENTITY.version !== EXPECTED_CURSOR_LOCAL_WIRE_VERSION || cursorManifest.version !== cursorPackage.version || cursorPackage.version !== CURSOR_SERVER_IDENTITY.version) { - fail('Cursor manifest, package, and cloud MCP server versions must remain pinned at 0.3.0; local wire identity must remain 0.1.0.'); + fail(`Cursor manifest, package, and cloud MCP server versions must remain pinned at ${EXPECTED_CURSOR_VERSION}; local wire identity must remain ${EXPECTED_CURSOR_LOCAL_WIRE_VERSION}.`); } if (cursorManifest.interface.displayName !== 'Cursor Cloud Control') fail('Cursor public display name mismatch.'); if (JSON.stringify(CURSOR_LOCAL_FOUNDATION_TOOLS.map((tool) => tool.name)) !== JSON.stringify(['status', 'run', 'runs']) @@ -169,11 +207,12 @@ for (const variable of [ 'CURSOR_LOCAL_CLI_SANDBOX_BIN', 'CURSOR_LOCAL_CLI_SANDBOX_SHA256', 'CURSOR_LOCAL_CLI_API_KEY', 'CURSOR_LOCAL_CLI_HOME', 'CURSOR_LOCAL_CLI_CONFIG_DIR', 'CURSOR_LOCAL_CLI_WORKSPACE_ROOTS', + 'CURSOR_LOCAL_CLI_ENABLE_HOST_TRUSTED_RUNS', 'CURSOR_LOCAL_CONTROL_STATE_DIR', 'XDG_STATE_HOME', 'HOME', ]) { if (!localServer.env_vars?.includes(variable)) fail(`Cursor local MCP manifest is missing ${variable}.`); } -if (localServer.env_vars?.includes('CURSOR_LOCAL_CLI_ENABLE_RUNS')) fail('Cursor local MCP manifest must not ship an execution activation switch.'); +if (localServer.env_vars?.includes('CURSOR_LOCAL_CLI_ENABLE_RUNS')) fail('Cursor local MCP manifest must not ship the legacy execution activation switch.'); if (packageJson.scripts?.test !== 'node --no-warnings --test test/*.test.mjs') { fail('Co-Engineer package test script must explicitly select test/*.test.mjs.'); } @@ -181,15 +220,64 @@ if (typeof cursorPackage.scripts?.test !== 'string' || !cursorPackage.scripts.te fail('Cursor package test script is missing.'); } if (JSON.stringify(packageJson.files) !== JSON.stringify([ - '.codex-plugin', '.mcp.json', 'README.md', 'assets', 'bin', 'mcp', 'skills', 'package.json', + '.codex-plugin', '.mcp.json', 'LICENSE', 'README.md', 'assets', 'bin', 'mcp', 'skills', 'package.json', ])) { fail('Co-Engineer package inventory roots changed.'); } if (JSON.stringify(cursorPackage.files) !== JSON.stringify([ - '.codex-plugin', '.mcp.json', 'README.md', 'mcp', 'skills', 'test', 'package.json', + '.codex-plugin', '.mcp.json', 'LICENSE', 'README.md', 'mcp', 'skills', 'package.json', ])) { fail('Cursor package inventory roots changed.'); } +const rootLicenseText = await text('LICENSE'); +for (const [name, packageManifest, licenseRelative] of [ + ['Co-Engineer', packageJson, 'plugins/plumbob-harness-control/LICENSE'], + ['Cursor', cursorPackage, 'plugins/cursor-cloud-control/LICENSE'], +]) { + if (packageManifest.license !== 'MIT' || !packageManifest.files.includes('LICENSE') + || await text(licenseRelative) !== rootLicenseText) { + fail(`${name} package must declare MIT and publish its package-local LICENSE text.`); + } +} + +const exampleTargetKeys = [ + 'schema_version', 'mode', 'working_directory', 'expected_git_root', + 'expected_head', 'allowed_paths', 'role', +]; +if (JSON.stringify(Object.keys(exampleTargetContext).sort()) !== JSON.stringify([...exampleTargetKeys].sort()) + || exampleTargetContext.schema_version !== 'codex-co-engineer.target.v1' + || exampleTargetContext.mode !== 'explicit' + || !/^\//u.test(exampleTargetContext.working_directory) + || !/^\//u.test(exampleTargetContext.expected_git_root) + || !/^[0-9a-f]{40}$/iu.test(exampleTargetContext.expected_head) + || !Array.isArray(exampleTargetContext.allowed_paths) + || exampleTargetContext.allowed_paths.length < 1 + || !['review', 'verify', 'implement'].includes(exampleTargetContext.role)) { + fail('examples/target-context.json must be a runnable explicit target-context template.'); +} +if (Object.hasOwn(exampleTargetContext, 'expected_fingerprint') + || Object.hasOwn(exampleTargetContext, 'expected_target_fingerprint')) { + fail('examples/target-context.json must not embed a fingerprint assertion; compute it with target-fingerprint.mjs and send it as expected_target_fingerprint.'); +} + +if (examplePreflightResult.ok !== true + || Object.hasOwn(examplePreflightResult, 'status') + || examplePreflightResult.schema_version !== 'codex-co-engineer.config.v1' + || !DIGEST_PATTERN.test(examplePreflightResult.target_fingerprint) + || examplePreflightResult.expected_target_fingerprint !== examplePreflightResult.target_fingerprint + || examplePreflightResult.target_binding !== 'control_plane' + || examplePreflightResult.target_match !== true + || !path.isAbsolute(examplePreflightResult.resolved_workspace) + || !path.isAbsolute(examplePreflightResult.resolved_cwd) + || examplePreflightResult.resolved_workspace !== exampleTargetContext.expected_git_root + || examplePreflightResult.resolved_cwd !== exampleTargetContext.working_directory + || !DIGEST_PATTERN.test(examplePreflightResult.configuration_digest) + || examplePreflightResult.transport !== 'stdio' + || examplePreflightResult.protocol_version !== EXPECTED_MCP_PROTOCOL_VERSION + || JSON.stringify(examplePreflightResult.server_identity) !== JSON.stringify(SERVER_IDENTITY) + || JSON.stringify(examplePreflightResult.available_tools) !== JSON.stringify(EXPECTED_CO_ENGINEER_TOOLS)) { + fail('examples/preflight-result.json must match the current successful Co-Engineer preflight receipt and target context.'); +} for (const section of ['transport', 'runtime', 'credentials', 'target', 'deadlines']) { if (configurationSchema.properties?.[section]?.additionalProperties !== false) { @@ -341,7 +429,8 @@ for (const plugin of ['plumbob-harness-control', 'cursor-cloud-control']) { // intentionally not a recursive repository walk: the release gate validates // the explicit candidate inventory above and must not absorb unrelated files. const scanned = [ - '.codex/release-gate.toml', '.github/workflows/ci.yml', 'docs/release.md', + '.agents/plugins/marketplace.json', '.codex/release-gate.toml', + '.github/workflows/ci.yml', 'docs/release.md', 'scripts/release-prerequisites.mjs', 'scripts/validate-release.mjs', ]; const forbidden = [ @@ -358,4 +447,4 @@ for (const relative of scanned) { } } -process.stdout.write(`release validation passed (${required.length} required files, Co-Engineer ${packageJson.version}, ACPX ${runtimeManifest.source.version})\n`); +process.stdout.write(`release validation passed (${required.length} required files, Co-Engineer ${packageJson.version}, Cursor ${cursorPackage.version}, local wire ${CURSOR_LOCAL_SERVER_IDENTITY.version}, ACPX ${runtimeManifest.source.version})\n`); From 78d8f544041b7e221b980c3add5991265940cf7d Mon Sep 17 00:00:00 2001 From: ajhcs <176340565+ajhcs@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:19:05 +0000 Subject: [PATCH 2/2] Fix ACP event-ledger lock handoff race --- .../mcp/acp-event-ledger.mjs | 13 +++++- .../test/acp-event-ledger.test.mjs | 46 ++++++++++++++++++- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/plugins/plumbob-harness-control/mcp/acp-event-ledger.mjs b/plugins/plumbob-harness-control/mcp/acp-event-ledger.mjs index 3988fdb..e68e621 100644 --- a/plugins/plumbob-harness-control/mcp/acp-event-ledger.mjs +++ b/plugins/plumbob-harness-control/mcp/acp-event-ledger.mjs @@ -1,6 +1,6 @@ import { createHash, randomBytes } from 'node:crypto'; import { constants } from 'node:fs'; -import { link, lstat, mkdir, open, readFile, unlink } from 'node:fs/promises'; +import fs, { link, lstat, mkdir, open, readFile, unlink } from 'node:fs/promises'; import path from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import { TextDecoder } from 'node:util'; @@ -322,7 +322,16 @@ export async function openAcpEventLedger({ state_root: stateRoot, session_id: se const lockIdentity = identity(listed); let existing; try { - existing = await open(lockPath, constants.O_RDONLY | NOFOLLOW); + // The listing is only an advisory snapshot. A live owner can release + // and unlink this exact path before we acquire a descriptor. Retry + // without making any path-based ownership decision; all non-ENOENT + // failures remain fail-closed below. + try { + existing = await fs.open(lockPath, constants.O_RDONLY | NOFOLLOW); + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } const opened = await existing.stat(); assertLockFile(opened, { allowCandidateLink: true }); if (!sameIdentity(lockIdentity, opened) || opened.size < 2 || opened.size > LOCK_PAYLOAD_BYTES) { diff --git a/plugins/plumbob-harness-control/test/acp-event-ledger.test.mjs b/plugins/plumbob-harness-control/test/acp-event-ledger.test.mjs index 8c5c3c7..a2dba2c 100644 --- a/plugins/plumbob-harness-control/test/acp-event-ledger.test.mjs +++ b/plugins/plumbob-harness-control/test/acp-event-ledger.test.mjs @@ -1,12 +1,14 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { +import { constants } from 'node:fs'; +import fs, { appendFile, chmod, link, lstat, mkdtemp, mkdir, + open, readFile, rename, rm, @@ -267,10 +269,11 @@ test('independent processes serialize through the owner-only writer lock', async const sessionId = 'process-session'; const moduleUrl = new URL('../mcp/acp-event-ledger.mjs', import.meta.url).href; const script = ` + import { writeSync } from 'node:fs'; import { openAcpEventLedger } from ${JSON.stringify(moduleUrl)}; const ledger = await openAcpEventLedger({ state_root: process.env.LEDGER_ROOT, session_id: process.env.LEDGER_SESSION }); const result = await ledger.append({ type: 'status', status: 'running' }); - process.stdout.write(String(result.event.seq)); + writeSync(1, String(result.event.seq)); await ledger.close(); `; function child() { @@ -294,6 +297,45 @@ test('independent processes serialize through the owner-only writer lock', async assert.equal((await ledger.inspect()).event_count, 2); }); +test('retries when a listed lock vanishes before stale-recovery open', async (context) => { + const root = await stateFixture(context); + const sessionId = 'vanished-lock-session'; + const ledger = await openAcpEventLedger({ state_root: root, session_id: sessionId }); + context.after(() => ledger.close()); + const lockPath = path.join(paths(root, sessionId).session, 'append.lock'); + const lock = await open(lockPath, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + const stat = await lock.stat(); + const payload = Buffer.from(`${JSON.stringify({ + pid: process.pid, + start: '0', + nonce: 'a'.repeat(64), + dev: String(stat.dev), + ino: String(stat.ino), + })}\n`, 'utf8'); + await lock.write(payload, 0, payload.length, 0); + await lock.sync(); + await lock.close(); + + const originalOpen = fs.open; + let vanished = false; + fs.open = async (...args) => { + if (!vanished && args[0] === lockPath) { + vanished = true; + await rm(lockPath, { force: true }); + } + return originalOpen(...args); + }; + try { + const result = await ledger.append(event(1)); + assert.equal(result.event.seq, 1); + } finally { + fs.open = originalOpen; + } + assert.equal(vanished, true, 'regression hook must remove the listed lock before open'); + assert.equal((await ledger.inspect()).last_seq, 1); + await assert.rejects(lstat(lockPath), { code: 'ENOENT' }); +}); + test('live lock owners are never stolen and a killed owner is recovered without a sequence gap', async (context) => { const root = await stateFixture(context); const sessionId = 'stale-lock-session';