From ff8ec41e6a1236694d28f463b60c7d136442f9c9 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 11 Nov 2026 07:28:00 -0800 Subject: [PATCH] feat(runner): upgrade ACPX to 0.22.0 and move patched behavior into Paperclip (#20770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip Runner and the shared `adapter-utils` engine run ACP agents through the embedded ACPX library > - Paperclip pins ACPX 0.13.1 and keeps a large patch (`patches/acpx@0.13.1.patch`) on it > - ACPX 0.22.0 adds host hooks (`onAcpMessage`, `assertActive`, `processLifecycle`, prompt-response `_meta`, `spawnAgent`-friendly launch) that make much of that patch unnecessary > - A large patch is expensive to port and hides behavior that belongs to Paperclip > - This pull request upgrades to ACPX 0.22.0 and moves most patched behavior into Runner and `adapter-utils` > - The benefit is a patch that is 484 lines instead of 1,334, with the moved behavior covered by Paperclip tests ## Linked Issues or Issue Description Refs #20675, #14430, #15075, #17942, #9980 **Proposed enhancement** Upgrade the embedded ACPX library from 0.13.1 to 0.22.0. Keep only the patch behavior that stock 0.22 cannot express. **Motivation** The 0.13.1 patch held behavior that Paperclip owns, for example usage receipts, Copilot policy, model checks and the agent environment. Each ACPX upgrade had to port all of it. ACPX 0.22 gives hooks that let Paperclip do this work itself. **Current behavior** Runner and `adapter-utils` depend on 1,334 lines of patched ACPX 0.13.1 code. **Expected behavior** Runner and `adapter-utils` use ACPX 0.22.0. The patch keeps only: extension methods, Cursor subagent routing, event `_meta` and the message boundary chunk, the protocol guard hook, `spawnAgent`, and `requestExtension`. ## What Changed - ACPX 0.13.1 โ†’ 0.22.0 in Runner, `adapter-utils`, the server, the Daytona runner images and the qualified profiles (`acpxVersion`). `@agentclientprotocol/sdk` moves to 1.9.0. - New `patches/acpx@0.22.0.patch` (484 lines). The 0.12.0 and 0.13.1 patches are removed. - Delivery receipts: Runner confirms a permission reply when `onAcpMessage` sees the exact outbound reply. Question and extension replies count as delivered when Runner hands them back to ACPX. New `interaction-delivery.ts`. - Copilot policy: the existing policy function runs from `onAcpMessage`. A violation stops the agent, cancels later permission requests and fails the next prompt through `assertActive`. - Model selection: stock 0.22 validation, plus a sidecar gate (`createAcpxEffectiveModelGate`) that refuses a prompt if the agent reports another model. Grok binds its model when the session opens. Antigravity adds its concrete model to the model list ACPX reads. - Typed session failures: Runner and `adapter-utils` read them from the turn result `_meta` and fail the turn. They advertise the capability themselves. - Pi and Cursor usage receipts: a collector (`prompt-receipts.ts`) binds them to the turn's own `session/prompt` reply. - Goal capability: Runner reads it from the `initialize` reply. It keeps it under `paperclip.goalCapability` in the saved agent capabilities `_meta` and restores it when a saved session loads. - Agent environment: Runner and `adapter-utils` set the agent environment in `spawnAgent`. ACP terminals keep their current environment. `adapter-utils` blocks inheritance of `XAI_API_KEY` and refuses to start if the server has an `ACPX_AUTH_*` variable. - `adapter-utils` spawn hooks (`spawnCwd`, stderr, `onAgentSpawn`): a new `stockAcpRuntimeOptions` maps them to stock `spawnAgent` and `processLifecycle`. - Windows: ACPX 0.22 rejects raw command strings on Windows. `adapter-utils` now passes an argv array there. - Grok rules: a Runner guard (`grok-session-rules.ts`) writes `session/new` `_meta.rules`. - Turn-dispatch journal (#20675): ACPX 0.22 ignores errors from `onAcpMessage`. The adapter keeps a failed `dispatched` write and throws it from the turn's `assertActive`. ACPX calls `assertActive` before the prompt write, so the write still stops. The real-ACPX test now checks this route. `docs/durable-recovery.md` is updated. - Plan events: the sidecar reads 0.22 `status` events tagged `plan`. - CI: the backup-checkpoint lane now installs through the shared `setup-ci-dependencies` action, like PR CI, so it resolves a stale `pnpm-lock.yaml` in place. It runs on any root `package.json` change, and pull requests never commit the lockfile, so its own frozen install failed on dependency changes. - Tests that checked removed patch internals are removed or rewritten: the response-delivery, Cursor-usage and Pi-receipt package contracts, and the goal-persistence packaging test. New unit tests cover the moved behavior. ## Verification Local runs on macOS (darwin-arm64), Node 26, on the rebased head: - `pnpm --filter @paperclipai/paperclip-runner exec vitest run src/drivers/acpx src/cli`: 2,000 passed. - `tsc -p packages/paperclip-runner/tsconfig.json` and `tsconfig.test.json`: no errors, except the existing `@paperclipai/paperclip-eval-kernel` build gap in `workflow-harness.ts`. - Runner package contracts (`node --test test/acpx-*-package-contract.test.mjs test/acpx-sidecar-contract.test.mjs`), `scripts/build-inputs.test.mjs`, `scripts/contracts/contract-fingerprints.test.mjs`, `scripts/acpx-patch-packaging.test.mjs`: all pass. `node scripts/generate-acpx-sidecar-contract.mjs --check` passes. - `cargo test -p paperclip-runner-core --test acpx_park_preparation --test acpx_park_readiness --test acpx_warm_attachment --test native_provider_backend`: 86 passed. `cargo fmt --check` passes. - `adapter-utils`: `tsc` clean. `vitest run src/acpx-engine src/mcp-isolation.integration.test.ts`: 457 passed (one Gemini flag test failed once under load and passes when run alone). - Adapters: `claude-local` and `codex-local` `acp.quota.test.ts` (23 and 39 passed), `grok-local` (149 passed). - Server: `scripts/prepare-runner-vendor.test.mjs`, `native-session-executor.test.ts`, `quarantined-task-retry.test.ts` (1,416 passed). - `cargo test -p paperclip-runner-core --lib -- previous_acpx_release acpx_upgrade unrestorable_saved_sessions`: 3 passed. `node --test test/acpx-session-carry-forward-package-contract.test.mjs`: passes. - `patch -p1 --forward` on a clean `acpx-0.22.0.tgz` gives output identical to the port. - Under full-suite load, some Runner live and recovery tests time out. Each one passes when run alone. Full regression is left to CI. Not run: live provider runs (they need provider credentials), the opt-in Kimi live peer test, the Gemini filesystem smoke test, and Windows. ## Risks - Previous-release story: sessions saved by the previous release (ACPX 0.13.1) carry forward. ACPX 0.22 loads the session records 0.13.1 wrote. Runner moves a saved descriptor that names 0.13.1 (`CARRIED_ACPX_RELEASES`) to 0.22.0, and the sidecar keeps 0.13.1 as the ACPX value in the session identity, so the saved session key and identity record still match. The existing launch-profile rebind still decides which sessions resume: a settled session, or the same run's interrupted turn through lost-turn recovery. A session saved by any other ACPX release is still refused as `provider_state_incompatible`. `rules/acpx-profile-identity.mdc` and `docs/durable-recovery.md` now describe this. - Proof: `acpx-session-carry-forward-package-contract.test.mjs` resumes a session record written by the real ACPX 0.13.1 with its Paperclip patch. 0.22 loads the same provider session (no `session/new`), runs a turn and keeps the earlier turn. Rust tests cover a settled 0.13.1 session that the next run attaches to, and a turn interrupted by the upgrade. A recovery-identity test checks that the session key and identity record match across the versions. - `pnpm-lock.yaml` is not committed. CI resolves it for each run, and the lockfile refresh workflow updates it after merge. - Behavior change on the server: `adapter-utils` refuses to create an ACPX runtime if the server environment has an `ACPX_AUTH_*` variable. Before, ACPX ignored such variables. Stock 0.22 would use them to authenticate agents with server credentials. - Delivery confirmation is now Runner-owned. A permission reply is confirmed by the exact outbound message. Question and extension replies are confirmed when Runner hands them to ACPX, not when the pipe accepts them. - Users can select only models that the agent advertises. Stock 0.22 rejects other model IDs (Claude is exempt). - ACP terminals keep the sidecar or server environment, as before. This existing exposure is not changed by this pull request. - The `adapter-utils` Gemini version probe now runs with the server environment plus the run variables, not the run variables alone. - The pack contract digest and profile `acpxVersion` change, so Daytona needs a new runner image. - Grok launch flags (`--model`, `--reasoning-effort`) were checked on 1.0.13 but session open needs a login, so Grok keeps the existing `_meta` path. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected โ€” check the roadmap first. See `CONTRIBUTING.md`. ## Model Used Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with tool use (shell, file editing, subagents). Extended reasoning was used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../backup-checkpoint-qualification.yml | 16 +- docker/daytona-runner/Dockerfile | 2 +- docker/daytona-runner/Dockerfile.release | 2 +- docker/daytona-runner/README.md | 2 +- package.json | 3 +- packages/adapter-utils/package.json | 2 +- .../src/acpx-engine/execute.test.ts | 47 +- .../adapter-utils/src/acpx-engine/execute.ts | 64 +- .../acpx-engine/filesystem-protocol.test.ts | 13 +- .../acpx-engine/remote-spawn-smoke.test.ts | 18 +- .../src/acpx-engine/run-site-host.ts | 2 + .../acpx-engine/stock-runtime-options.test.ts | 104 ++ .../src/acpx-engine/stock-runtime-options.ts | 108 ++ .../acpx-engine/terminal-session-failure.ts | 45 + .../src/mcp-isolation.integration.test.ts | 3 +- .../claude-local/src/server/acp.quota.test.ts | 34 +- .../codex-local/src/server/acp.quota.test.ts | 14 +- packages/paperclip-runner/acpx-profiles.json | 2 +- .../paperclip-runner/docs/durable-recovery.md | 14 +- packages/paperclip-runner/package.json | 4 +- .../runner-core/src/acpx_provider_backend.rs | 78 +- .../src/generated_acpx_profiles.rs | 2 +- .../tests/acpx_park_preparation.rs | 4 +- .../runner-core/tests/acpx_park_readiness.rs | 4 +- .../runner-core/tests/acpx_warm_attachment.rs | 20 +- .../tests/native_provider_backend.rs | 4 +- .../scripts/build-inputs.test.mjs | 6 +- .../contracts/contract-fingerprints.test.mjs | 7 +- .../scripts/patch-kimi-runtime.test.mjs | 12 +- .../src/backends/codex-native-backend.ts | 3 +- .../backends/native-backend-factory.test.ts | 10 +- .../src/cli/acpx-runtime-sidecar.test.ts | 6 +- .../src/cli/acpx-runtime-sidecar.ts | 10 +- .../local-provider-fault-matrix.test.ts | 4 +- .../src/contracts/native-execution.test.ts | 6 +- .../src/drivers/acpx/antigravity-admission.ts | 13 +- .../drivers/acpx/codex-acpx-driver.test.ts | 8 +- .../src/drivers/acpx/codex-acpx-driver.ts | 2 +- .../acpx/codex-runtime-adapter.test.ts | 285 +++- .../src/drivers/acpx/codex-runtime-adapter.ts | 294 +++- .../src/drivers/acpx/copilot-policy.ts | 42 + .../acpx/copilot-protocol-guard.test.ts | 99 +- .../drivers/acpx/cursor-instructions.test.ts | 9 +- .../src/drivers/acpx/cursor-mode.test.ts | 4 +- .../acpx/cursor-permission-identity.test.ts | 10 +- .../acpx/cursor-sdk-interactions.test.ts | 11 +- .../src/drivers/acpx/cursor-usage-receipt.ts | 52 +- .../src/drivers/acpx/driver-profile.test.ts | 2 +- .../src/drivers/acpx/generated-profiles.ts | 18 +- .../acpx/grok-native-session-options.test.ts | 5 +- .../src/drivers/acpx/grok-session-rules.ts | 23 + .../drivers/acpx/interaction-delivery.test.ts | 151 ++ .../src/drivers/acpx/interaction-delivery.ts | 217 +++ .../drivers/acpx/model-verification.test.ts | 48 +- .../src/drivers/acpx/model-verification.ts | 88 ++ .../src/drivers/acpx/prompt-receipts.test.ts | 81 + .../src/drivers/acpx/prompt-receipts.ts | 67 + .../src/drivers/acpx/provider-failure.ts | 35 + .../drivers/acpx/recovery-identity.test.ts | 14 + .../src/drivers/acpx/recovery-identity.ts | 8 +- .../src/drivers/acpx/runtime-host.ts | 8 +- .../src/drivers/acpx/sidecar-protocol.test.ts | 14 + .../src/drivers/acpx/sidecar-protocol.ts | 11 + .../acpx/turn-dispatch-journal.test.ts | 8 +- .../src/drivers/acpx/usage-accounting.test.ts | 72 +- .../src/drivers/acpx/usage-accounting.ts | 71 +- .../src/issue-thread/fixtures.ts | 2 +- .../src/live/live-session.test.ts | 6 +- .../src/protocol/provider-descriptor.test.ts | 4 +- .../src/provider-events.test.ts | 20 + .../paperclip-runner/src/provider-events.ts | 4 + .../test/acpx-codex-package-contract.test.mjs | 60 +- ...cursor-subagents-package-contract.test.mjs | 4 +- ...cpx-cursor-usage-package-contract.test.mjs | 173 -- ...-model-selection-package-contract.test.mjs | 90 +- .../acpx-pi-receipt-package-contract.test.mjs | 90 -- ...esponse-delivery-package-contract.test.mjs | 100 -- ...-rich-extensions-package-contract.test.mjs | 9 +- ...on-carry-forward-package-contract.test.mjs | 75 + .../test/acpx-sidecar-contract.test.mjs | 2 +- .../fixtures/acpx-0.13.1-session-record.json | 63 + .../test/support/acpx-bundle-internals.d.mts | 5 + .../test/support/acpx-bundle-internals.mjs | 51 + patches/acpx@0.12.0.patch | 248 --- patches/acpx@0.13.1.patch | 1394 ----------------- patches/acpx@0.22.0.patch | 484 ++++++ pnpm-workspace.yaml | 3 +- rules/acpx-profile-identity.mdc | 10 +- scripts/acpx-patch-packaging.test.mjs | 61 +- scripts/prepare-bundled-package.mjs | 3 +- server/package.json | 2 +- server/scripts/prepare-runner-vendor.test.mjs | 2 +- .../native-session-executor.test.ts | 16 +- .../services/quarantined-task-retry.test.ts | 2 +- .../runner-e2e/gemini-acp-filesystem.smoke.ts | 18 +- .../runner-e2e/source-sdk-public-inputs.json | 2 +- 96 files changed, 2787 insertions(+), 2661 deletions(-) create mode 100644 packages/adapter-utils/src/acpx-engine/stock-runtime-options.test.ts create mode 100644 packages/adapter-utils/src/acpx-engine/stock-runtime-options.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/grok-session-rules.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/interaction-delivery.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/interaction-delivery.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/prompt-receipts.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/prompt-receipts.ts delete mode 100644 packages/paperclip-runner/test/acpx-cursor-usage-package-contract.test.mjs delete mode 100644 packages/paperclip-runner/test/acpx-pi-receipt-package-contract.test.mjs delete mode 100644 packages/paperclip-runner/test/acpx-response-delivery-package-contract.test.mjs create mode 100644 packages/paperclip-runner/test/acpx-session-carry-forward-package-contract.test.mjs create mode 100644 packages/paperclip-runner/test/fixtures/acpx-0.13.1-session-record.json create mode 100644 packages/paperclip-runner/test/support/acpx-bundle-internals.d.mts create mode 100644 packages/paperclip-runner/test/support/acpx-bundle-internals.mjs delete mode 100644 patches/acpx@0.12.0.patch delete mode 100644 patches/acpx@0.13.1.patch create mode 100644 patches/acpx@0.22.0.patch diff --git a/.github/workflows/backup-checkpoint-qualification.yml b/.github/workflows/backup-checkpoint-qualification.yml index d325d927e89..5298b20a549 100644 --- a/.github/workflows/backup-checkpoint-qualification.yml +++ b/.github/workflows/backup-checkpoint-qualification.yml @@ -12,6 +12,7 @@ on: - 'server/scripts/lib/backup-qualification.ts' - 'server/vitest.backup-qualification.config.ts' - '.github/workflows/backup-checkpoint-qualification.yml' + - '.github/actions/setup-ci-dependencies/**' - 'package.json' - 'pnpm-lock.yaml' push: @@ -45,15 +46,12 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: node-version: '24' - cache: pnpm - - name: Resolve dependency manifests for this run - run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile - - run: pnpm install --frozen-lockfile --ignore-scripts - - name: Build workspace import prerequisites - run: | - pnpm --filter @paperclipai/shared build - pnpm --filter @paperclipai/db build - pnpm --filter @paperclipai/plugin-sdk ensure-build-deps + package-manager-cache: false + # Installs dependencies and builds the shared and plugin SDK packages. + - name: Restore materialized dependencies and shared builds + uses: ./.github/actions/setup-ci-dependencies + - name: Build the database package + run: pnpm --filter @paperclipai/db build - name: Use the service image's matching real PostgreSQL clients env: PG_CONTAINER: ${{ job.services.postgres.id }} diff --git a/docker/daytona-runner/Dockerfile b/docker/daytona-runner/Dockerfile index fcbe31de754..e499138a2cc 100644 --- a/docker/daytona-runner/Dockerfile +++ b/docker/daytona-runner/Dockerfile @@ -172,7 +172,7 @@ RUN /bin/sh -lc 'set -eu; \ test "$(codex --version)" = "codex-cli 0.160.0"; \ test "$(claude --version)" = "2.1.286 (Claude Code)"; \ test "$(opencode --version)" = "1.18.34"; \ - test "$(acpx --version)" = "0.13.1"; \ + test "$(acpx --version)" = "0.22.0"; \ test "$(claude-agent-acp --version)" = "0.73.0"; \ test "$(codex-acp --version)" = "@agentclientprotocol/codex-acp 1.6.2"' diff --git a/docker/daytona-runner/Dockerfile.release b/docker/daytona-runner/Dockerfile.release index ea27e84a6b8..ffa9d378ffb 100644 --- a/docker/daytona-runner/Dockerfile.release +++ b/docker/daytona-runner/Dockerfile.release @@ -11,6 +11,6 @@ USER daytona RUN test -r /opt/paperclip-runner/provider-pack/provider-pack.json \ && test "$(claude --version)" = "2.1.286 (Claude Code)" \ && test "$(codex --version)" = "codex-cli 0.160.0" \ - && test "$(acpx --version)" = "0.13.1" + && test "$(acpx --version)" = "0.22.0" LABEL io.paperclip.runner.content-id="release-${SOURCE_REVISION}" \ org.opencontainers.image.revision="${SOURCE_REVISION}" diff --git a/docker/daytona-runner/README.md b/docker/daytona-runner/README.md index adf1779a041..1b585f93f83 100644 --- a/docker/daytona-runner/README.md +++ b/docker/daytona-runner/README.md @@ -8,7 +8,7 @@ it runs explicitly on the execution host and is never an npm installation hook. This image is the Paperclip Cloud fleet sandbox image plus a source-built `paperclip-runnerd` and immutable provider pack. The pack contains Node 24.11, -OpenCode 1.18.34, the compiled OpenCode proxy, ACPX 0.13.1 sidecar, qualified ACP +OpenCode 1.18.34, the compiled OpenCode proxy, ACPX 0.22.0 sidecar, qualified ACP agents, and the production lockfile. Its manifest digests each executable bridge and records the runner source revision as provenance. Its interface declaration allows compatible app releases to reuse it without uploading or installing a pack. diff --git a/package.json b/package.json index 88c3caa4d83..e704ca28508 100644 --- a/package.json +++ b/package.json @@ -115,8 +115,7 @@ "pnpm": { "patchedDependencies": { "embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch", - "acpx@0.12.0": "patches/acpx@0.12.0.patch", - "acpx@0.13.1": "patches/acpx@0.13.1.patch", + "acpx@0.22.0": "patches/acpx@0.22.0.patch", "@agentclientprotocol/claude-agent-acp@0.73.0": "patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch", "@agentclientprotocol/codex-acp@1.6.2": "patches/@agentclientprotocol__codex-acp@1.6.2.patch", "@chat-adapter/telegram@4.39.0": "patches/@chat-adapter__telegram@4.39.0.patch", diff --git a/packages/adapter-utils/package.json b/packages/adapter-utils/package.json index 5b1bc75980f..f0b94b6b67a 100644 --- a/packages/adapter-utils/package.json +++ b/packages/adapter-utils/package.json @@ -44,7 +44,7 @@ }, "dependencies": { "@paperclipai/shared": "workspace:*", - "acpx": "0.12.0", + "acpx": "0.22.0", "picocolors": "^1.1.1", "smol-toml": "^1.9.1" }, diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index cd5030d3a0a..82ba09015c7 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -59,6 +59,7 @@ import { runWithRuntimeParent, SANDBOX_STARTUP_SPAN_ATTRS, } from "./startup-timing.js"; +import type { AcpxAgentSpawnHooks } from "./stock-runtime-options.js"; const tempRoots: string[] = []; @@ -415,8 +416,9 @@ describe("shared ACPX engine runtime behavior", () => { expect(turnInputs[0]).toMatchObject({ handle: { backendSessionId: "backend-session", agentSessionId: "agent-session", runtimeSessionName: "runtime-session" }, text: meta[0]?.prompt, mode: "prompt", requestId: "run-1", timeoutMs: 120_000, - onTerminalSessionFailure: expect.any(Function), }); + // Typed failures arrive on the turn result `_meta`, not through an ACPX callback. + expect(turnInputs[0]).not.toHaveProperty("onTerminalSessionFailure"); expect(meta[0]?.prompt).toBeTypeOf("string"); expect(turnInputs[0]?.text).toBeTypeOf("string"); expect((meta[0]?.prompt as string).length).toBeGreaterThan(0); @@ -2818,10 +2820,35 @@ describe("shared ACPX engine runtime behavior", () => { expect(fp(second)).toBe(fp(first)); }); + it("launches the agent with exactly this run's prepared environment and blocks the host Grok key", async () => { + const root = await makeTempRoot(); + let runtimeOptions: (AcpRuntimeOptions & AcpxAgentSpawnHooks) | undefined; + let sessionEnvironment: Record | undefined; + const execute = createAcpxEngineExecutor({ + createRuntime: (options) => { + runtimeOptions = options; + const runtime = buildRuntime() as { ensureSession: (input: { sessionOptions?: { env?: Record } }) => Promise }; + const ensure = runtime.ensureSession; + runtime.ensureSession = async (input) => { sessionEnvironment = input.sessionOptions?.env; return await ensure(input); }; + return runtime as never; + }, + }); + const result = await execute({ + runId: "run-environment", agent: { id: "agent-1", companyId: "company-1" }, runtime: {}, + config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd: root }, + context: {}, onLog: async () => {}, onMeta: async () => {}, + } as never); + expect(result.exitCode).toBe(0); + expect(runtimeOptions).not.toHaveProperty("spawnEnvironment"); + expect(runtimeOptions?.agentProcessEnvBlock).toEqual(["XAI_API_KEY"]); + expect(sessionEnvironment).toBeDefined(); + expect(runtimeOptions?.agentEnvironment?.()).toEqual(sessionEnvironment); + }); + it("routes child stderr in-process while keeping the unfiltered run log", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); - let runtimeOptions: AcpRuntimeOptions | undefined; + let runtimeOptions: (AcpRuntimeOptions & AcpxAgentSpawnHooks) | undefined; const execute = createAcpxEngineExecutor({ createRuntime: (options) => { runtimeOptions = options; @@ -2862,7 +2889,7 @@ describe("shared ACPX engine runtime behavior", () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); const warmHandles = new Map(); - let runtimeOptions: AcpRuntimeOptions | undefined; + let runtimeOptions: (AcpRuntimeOptions & AcpxAgentSpawnHooks) | undefined; const execute = createAcpxEngineExecutor({ warmHandles, createRuntime: (options) => { @@ -3530,10 +3557,14 @@ describe("ACP activity diagnostics", () => { await fs.mkdir(cwd, { recursive: true }); const execute = createAcpxEngineExecutor({ classifyTerminalSessionFailure: () => ({ errorCode: "claude_transient_upstream", errorFamily: "transient_upstream" }), - createRuntime: () => ({ ...buildRuntime(), startTurn: (options: { onTerminalSessionFailure: (failure: unknown) => void }) => { - options.onTerminalSessionFailure({ category: "service", details: "API Error: 400 tools.17.custom.name: String should have at most 128 characters" }); - return { events: (async function* () {})(), result: Promise.resolve({ status: "failed", error: new Error("turn failed") }), cancel: async () => {} }; - } }) as never, + createRuntime: (options: { clientCapabilities?: unknown }) => { + expect(options.clientCapabilities).toEqual({ _meta: { jetbrains: { air: { version: 1, capabilities: ["sessionFailure"] } } } }); + // The agent reports the failure on its prompt response; ACP's stop reason stays normal. + return { ...buildRuntime(), startTurn: () => ({ events: (async function* () {})(), cancel: async () => {}, + result: Promise.resolve({ status: "completed", stopReason: "end_turn", _meta: { jetbrains: { air: { version: 1, sessionFailure: { + severity: "error", category: "service", details: "API Error: 400 tools.17.custom.name: String should have at most 128 characters", + } } } } }) }) } as never; + }, }); const result = await execute({ runId: "invalid-definition", agent: { id: "agent-1", companyId: "company-1" }, runtime: {}, config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd, env: { SECRET: "128" } }, @@ -6739,7 +6770,7 @@ describe("ACPX engine per-step startup timing (run.startup.step events)", () => const root = await makeTempRoot(); const warmHandles = new Map(); const events: Array<{ eventType: string; payload?: Record }> = []; - let runtimeOptions: AcpRuntimeOptions; + let runtimeOptions: AcpRuntimeOptions & AcpxAgentSpawnHooks; const runtime = buildRuntime(undefined, () => { // Exercise the real engine spawn observer, including a second launch in // the same ensure call. A session ID cannot hide either startup cost. diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 3db5489d12b..3e12f7014f3 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -51,9 +51,12 @@ import { classifyToolDefinitionFailure, formatTerminalSessionFailure, sanitizeTerminalSessionFailure, + typedSessionFailureClientCapabilities, + typedTerminalTurnResult, type AcpxTerminalSessionFailure, type AcpxTerminalSessionFailureDiagnostic, } from "./terminal-session-failure.js"; +import { assertNoHostAcpxAuthEnvironment, splitWindowsAgentCommand, stockAcpRuntimeOptions, type AcpxAgentSpawnHooks } from "./stock-runtime-options.js"; export type { AcpxTerminalSessionFailure } from "./terminal-session-failure.js"; import type { WorkspaceRestoreFailureCode, WorkspaceRestoreOutcome } from "../workspace-restore-merge.js"; import type { WorkspaceRestoreDiagnostic } from "../workspace-restore-diagnostics.js"; @@ -211,12 +214,12 @@ function flushChildStderr(state: ChildStderrState) { state.pendingLiveLine = ""; } -type PaperclipAcpRuntimeOptions = AcpRuntimeOptions & { +type PaperclipAcpRuntimeOptions = AcpRuntimeOptions & AcpxAgentSpawnHooks & { onAgentSpawn?: (meta: AcpxAgentProcessIdentity) => Promise; // Return the current-run parent-context token. It is the `task.run` token // during startup and after the turn, and the `agent.turn` token during the // turn. A detached exec reads this getter to parent to the live run span. The - // real `createAcpRuntime` ignores this optional field. + // real runtime factory drops this field before creating the ACPX runtime. getRuntimeParentContext?: () => StartupSpanContext | undefined; }; @@ -2656,11 +2659,16 @@ async function buildRuntime(input: { } // The relay runs on the host with the sanitized remote launch environment. // Its /usr/bin/env node shebang cannot rely on that environment's PATH. - const overrideCommand = processSessionBridge?.agentCommand - ? [process.execPath, processSessionBridge.agentCommand] - .map((part) => JSON.stringify(part.replaceAll("\\", "/"))).join(" ") + const relayArgv = processSessionBridge?.agentCommand + ? [process.execPath, processSessionBridge.agentCommand].map((part) => part.replaceAll("\\", "/")) + : null; + const overrideCommand = relayArgv + ? relayArgv.map((part) => JSON.stringify(part)).join(" ") : agentCommand; - const overrides = overrideCommand ? { [acpxAgent]: overrideCommand } : undefined; + // ACPX launches only argv arrays on Windows; keep POSIX command identities unchanged. + const overrides = overrideCommand + ? { [acpxAgent]: process.platform === "win32" ? relayArgv ?? splitWindowsAgentCommand(overrideCommand) : overrideCommand } + : undefined; const agentRegistry = createAgentRegistry({ overrides }); const loggedEnv = buildInvocationEnvForLogs(env, { runtimeEnv, @@ -4067,7 +4075,12 @@ function openTurnSpan( } export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { - const createRuntime = deps.createRuntime ?? createAcpRuntime; + const createRuntime = deps.createRuntime + ?? ((options: PaperclipAcpRuntimeOptions) => { + assertNoHostAcpxAuthEnvironment(); + const { getRuntimeParentContext: _parentContext, ...acpx } = stockAcpRuntimeOptions(options); + return createAcpRuntime(acpx); + }); const now = deps.now ?? (() => Date.now()); const duplexLossCancelDeadlineMs = deps.duplexLossCancelDeadlineMs ?? ACPX_DUPLEX_LOSS_CANCEL_DEADLINE_MS; const stalledToolCheckIntervalMs = deps.stalledToolCheckIntervalMs ?? ACPX_STALLED_TOOL_CHECK_INTERVAL_MS; @@ -4351,6 +4364,8 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { // `endSession` step can cancel a running turn before it closes the runtime // (the cancel-before-close order). The turn wrapper assigns it in `turnStart`. let activeTurn: AcpRuntimeTurn | null = null; + // Receives this turn's typed AIR failure, read from its prompt response `_meta`. + let activeTurnFailureHandler: (failure: AcpxTerminalSessionFailure) => void = () => {}; let terminalFailureClassification: AcpxTerminalFailureClassification | null = null; let terminalSessionFailure: AcpxTerminalSessionFailureDiagnostic | null = null; // How the settlement `endSession` step must release the runtime for the path @@ -4498,6 +4513,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { // target mutable so a later agent respawn records identity on the current // heartbeat instead of the run that originally created the runtime. processIdentitySink.current = ctx.onSpawn; + processIdentitySink.launchEnvironment = prepared.env; // The process-session bridge starts its provider inside the sandbox. // ACPX observes only the host relay on that lane, so neither its spawn // callback nor a cached relay establishes physical provider reuse. @@ -4513,6 +4529,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { // Resume with this run's launch environment; keep it out of the saved // conversation record without mutating the live runtime's options. const runtimeStore = createEphemeralSessionEnvironmentStore(persistedRuntimeStore, prepared.env); + const launchSink = processIdentitySink; const runtimeOptions: PaperclipAcpRuntimeOptions = { cwd: prepared.cwd, // Host-only spawn cwd for the relay proxy on the remote process-session @@ -4533,8 +4550,13 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { verbose: prepared.acpxAgent === "claude", // The engine passes a complete, sanitized launch environment. ACPX // must not merge the Paperclip server's ambient environment back in - // when it spawns the provider child. - inheritProcessEnv: false, + // when it spawns the provider child, so the spawn uses exactly it. + agentEnvironment: () => launchSink.launchEnvironment ?? {}, + // ACPX enables Grok's API-key login only when the agent would inherit + // XAI_API_KEY; the server's own key must never qualify. + agentProcessEnvBlock: ["XAI_API_KEY"], + // ACP agents that support it report terminal failures as typed AIR metadata. + clientCapabilities: typedSessionFailureClientCapabilities(), // Observe permission requests without deciding them: returning // undefined leaves the decision to ACPX's permission-mode resolver. onPermissionRequest: async (request) => { @@ -5087,6 +5109,15 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { // After entering startTurn, a thrown error cannot prove the provider // received no work. Only failures before this boundary are free to settle. turnDispatchAttempted = true; + // Diagnostics are retained even when an adapter has no recovery + // classifier. Redact before bounding so partial secrets cannot leak. + activeTurnFailureHandler = (failure: AcpxTerminalSessionFailure) => { + terminalFailureClassification = classifyToolDefinitionFailure(failure) + ?? deps.classifyTerminalSessionFailure?.(failure, new Date(now())) ?? null; + terminalSessionFailure = sanitizeTerminalSessionFailure( + failure, prepared.env, ctx.authToken, parseObject(ctx.config.env), + ); + }; const turn = runtime.startTurn({ handle: sessionHandle, text: runPrompt, @@ -5094,16 +5125,6 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { requestId: ctx.runId, timeoutMs: startTimeoutMs, signal, - // The callback belongs to this turn, including when a runtime is reused. - // Diagnostics are retained even when an adapter has no recovery - // classifier. Redact before bounding so partial secrets cannot leak. - onTerminalSessionFailure: (failure: AcpxTerminalSessionFailure) => { - terminalFailureClassification = classifyToolDefinitionFailure(failure) - ?? deps.classifyTerminalSessionFailure?.(failure, new Date(now())) ?? null; - terminalSessionFailure = sanitizeTerminalSessionFailure( - failure, prepared.env, ctx.authToken, parseObject(ctx.config.env), - ); - }, }); activeTurn = turn; // A latched sandbox duplex-channel loss otherwise has no way to reach @@ -5297,7 +5318,10 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { // `turn.result` settles only when the agent's provider process // returns or rejects; a latched loss that armed the deadline after // the event drain already ended must still bound this wait. - return await Promise.race([turn.result, lossDeadline.then(cancelDeadlineTerminal)]); + // The typed-failure handler belongs to this turn, including when a runtime is reused. + const onTerminalSessionFailure = activeTurnFailureHandler; + const result = turn.result.then((settled) => typedTerminalTurnResult(settled, onTerminalSessionFailure)); + return await Promise.race([result, lossDeadline.then(cancelDeadlineTerminal)]); }; const stepTurnFinalize = async ( input: TurnFinalizeInput, diff --git a/packages/adapter-utils/src/acpx-engine/filesystem-protocol.test.ts b/packages/adapter-utils/src/acpx-engine/filesystem-protocol.test.ts index 44da2874807..78f2c35d018 100644 --- a/packages/adapter-utils/src/acpx-engine/filesystem-protocol.test.ts +++ b/packages/adapter-utils/src/acpx-engine/filesystem-protocol.test.ts @@ -2,15 +2,12 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { createRequire } from "node:module"; import { afterEach, describe, expect, it } from "vitest"; import { createAcpRuntime, createAgentRegistry, createRuntimeStore } from "acpx/runtime"; +import { stockAcpRuntimeOptions } from "./stock-runtime-options.js"; -const runnerRuntimePath = createRequire(new URL("../../../paperclip-runner/package.json", import.meta.url)).resolve("acpx/runtime"); -const nativeRuntime = await import(runnerRuntimePath) as typeof import("acpx/runtime"); const runtimes = [ - { version: "0.12.0", api: { createAcpRuntime, createAgentRegistry, createRuntimeStore } }, - { version: "0.13.1", api: nativeRuntime }, + { version: "0.22.0", api: { createAcpRuntime, createAgentRegistry, createRuntimeStore } }, ]; const roots: string[] = []; const fixture = fileURLToPath(new URL("../../../../scripts/mcp-fixtures/servers/acp-filesystem-agent.mjs", import.meta.url)); @@ -33,15 +30,15 @@ async function drive(api: (typeof runtimes)[number]["api"], input: { operations: Array<{ method: string; path: string; content?: string }>; }) { let stderr = ""; - const runtime = api.createAcpRuntime({ + const runtime = api.createAcpRuntime(stockAcpRuntimeOptions({ cwd: input.cwd, sessionStore: api.createRuntimeStore({ stateDir: path.join(input.root, "state") }), agentRegistry: api.createAgentRegistry({ overrides: { custom: `${JSON.stringify(process.execPath.replaceAll("\\", "/"))} ${JSON.stringify(fixture.replaceAll("\\", "/"))}` } }), permissionMode: input.permissionMode ?? "approve-all", nonInteractivePermissions: "deny", - inheritProcessEnv: false, + agentEnvironment: () => ({}), onAgentStderr: (chunk: string) => { stderr += chunk; }, - }); + })); const handle = await runtime.ensureSession({ sessionKey: "filesystem-protocol", agent: "custom", mode: "oneshot", cwd: input.cwd, sessionOptions: { env: {} }, diff --git a/packages/adapter-utils/src/acpx-engine/remote-spawn-smoke.test.ts b/packages/adapter-utils/src/acpx-engine/remote-spawn-smoke.test.ts index efaa315b9ed..e5c867b71f3 100644 --- a/packages/adapter-utils/src/acpx-engine/remote-spawn-smoke.test.ts +++ b/packages/adapter-utils/src/acpx-engine/remote-spawn-smoke.test.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { afterEach, expect, it } from "vitest"; import type { AcpRuntimeOptions } from "acpx/runtime"; import { createAcpRuntime, createAgentRegistry, createRuntimeStore } from "acpx/runtime"; +import { stockAcpRuntimeOptions, type AcpxAgentSpawnHooks } from "./stock-runtime-options.js"; // Load-bearing repro for the remote-ACP "process session" lane host-spawn bug. // @@ -21,7 +22,7 @@ import { createAcpRuntime, createAgentRegistry, createRuntimeStore } from "acpx/ // either materializing `remoteCwd` on the host (masking the host-spawn ENOENT) // or failing earlier at a different phase. So we split the proof at its two real // seams: (1) here โ€” the acpx runtime's REAL host `spawn()` honoring -// `spawnCwd ?? cwd` (real `createAcpRuntime`, real libuv `chdir`); and (2) the +// `spawnCwd` through stock `spawnAgent` (real `createAcpRuntime`, real libuv `chdir`); and (2) the // engine โ†’ `runtimeOptions` threading (`execute.test.ts`, which asserts the // engine sets `spawnCwd` host-valid on the remote lane and `undefined` // elsewhere, with the advertised `session/new` cwd staying `remoteCwd`). @@ -34,10 +35,7 @@ const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url)); const fixturePath = path.join(repoRoot, "scripts", "mcp-fixtures", "servers", "acp-cwd-report-agent.mjs"); const tempRoots: string[] = []; -type PatchedAcpRuntimeOptions = AcpRuntimeOptions & { - onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise; - spawnCwd?: string; -}; +type PatchedAcpRuntimeOptions = AcpRuntimeOptions & AcpxAgentSpawnHooks; type PatchedEnsureSessionOptions = Parameters["ensureSession"]>[0]; @@ -54,8 +52,8 @@ async function makeTempDir(prefix: string): Promise { /** * Drive a REAL acpx runtime through `ensureSession`, which performs the real * host `spawn()` (and its libuv `chdir`). `cwd` is the advertised session cwd; - * `spawnCwd`, when set, is the host-only spawn cwd the acpx patch consumes as - * `spawnCwd ?? cwd`. + * `spawnCwd`, when set, is the host-only spawn cwd the engine applies through + * stock `spawnAgent`. */ async function ensureRealAcpSession(input: { cwd: string; @@ -67,8 +65,8 @@ async function ensureRealAcpSession(input: { const agentCommand = `${JSON.stringify(process.execPath.replaceAll("\\", "/"))} ${JSON.stringify(fixturePath.replaceAll("\\", "/"))}`; const runtimeOptions: PatchedAcpRuntimeOptions = { cwd: input.cwd, - // `spawnCwd` is the host-only knob added by patches/acpx@0.12.0.patch; when - // unset acpx falls back to `cwd`, so every non-proxy lane is byte-identical. + // `spawnCwd` is the engine's host-only knob, applied through stock + // `spawnAgent`; when unset acpx uses `cwd`, so non-proxy lanes are unchanged. ...(input.spawnCwd ? { spawnCwd: input.spawnCwd } : {}), sessionStore: createRuntimeStore({ stateDir: path.join(stateRoot, "state") }), agentRegistry: createAgentRegistry({ overrides: { custom: agentCommand } }), @@ -77,7 +75,7 @@ async function ensureRealAcpSession(input: { onAgentStderr: (chunk: string) => stderrChunks.push(chunk), onAgentSpawn: input.onAgentSpawn, }; - const runtime = createAcpRuntime(runtimeOptions); + const runtime = createAcpRuntime(stockAcpRuntimeOptions(runtimeOptions)); try { const sessionInput: PatchedEnsureSessionOptions = { diff --git a/packages/adapter-utils/src/acpx-engine/run-site-host.ts b/packages/adapter-utils/src/acpx-engine/run-site-host.ts index e5fba94c0ed..64490d53bd8 100644 --- a/packages/adapter-utils/src/acpx-engine/run-site-host.ts +++ b/packages/adapter-utils/src/acpx-engine/run-site-host.ts @@ -40,6 +40,8 @@ export type AcpxProcessIdentitySink = { onLaunch?: () => void; latest: AcpxAgentProcessIdentity | null; localProcess?: ChildProcess; + /** The current run's agent environment; a warm runtime respawns with this run's values. */ + launchEnvironment?: Readonly>; }; /** The live-line buffer and log path a warm runtime's child stderr carries. */ diff --git a/packages/adapter-utils/src/acpx-engine/stock-runtime-options.test.ts b/packages/adapter-utils/src/acpx-engine/stock-runtime-options.test.ts new file mode 100644 index 00000000000..a7a8d11be37 --- /dev/null +++ b/packages/adapter-utils/src/acpx-engine/stock-runtime-options.test.ts @@ -0,0 +1,104 @@ +import { EventEmitter } from "node:events"; +import type { ChildProcess } from "node:child_process"; +import { describe, expect, it, vi } from "vitest"; +import { assertNoHostAcpxAuthEnvironment, splitWindowsAgentCommand, stockAcpRuntimeOptions } from "./stock-runtime-options.js"; +import { typedTerminalSessionFailure, typedTerminalTurnResult } from "./terminal-session-failure.js"; + +function fakeChild(pid: number): ChildProcess { + return Object.assign(new EventEmitter(), { pid, stderr: new EventEmitter() }) as unknown as ChildProcess; +} + +const base = { cwd: "/advertised" } as Parameters[0]; +const launch = { launchId: "l", scope: { kind: "client" as const }, command: "agent", args: [], cwd: "/advertised" }; + +describe("stock ACPX spawn options", () => { + it("passes options through untouched without engine hooks", () => { + expect(stockAcpRuntimeOptions(base)).toEqual(base); + }); + + it("spawns in the host-only cwd and observes the agent's stderr", () => { + const child = fakeChild(41); + const spawnAgent = vi.fn((_input: unknown) => child); + const stderr: string[] = []; + const stock = stockAcpRuntimeOptions({ ...base, spawnAgent, spawnCwd: "/host", onAgentStderr: chunk => { stderr.push(chunk); throw new Error("ignored"); } }); + expect(stock).not.toHaveProperty("spawnCwd"); + expect(stock).not.toHaveProperty("onAgentStderr"); + expect(stock.spawnAgent!({ command: "agent", args: ["--acp"], options: { cwd: "/advertised", env: {} } })).toBe(child); + expect(spawnAgent).toHaveBeenCalledWith({ command: "agent", args: ["--acp"], options: { cwd: "/host", env: {} } }); + child.stderr!.emit("data", Buffer.from("warning\n")); + expect(stderr).toEqual(["warning\n"]); + }); + + it("admits only the agent process, not ACPX command probes", async () => { + const onAgentSpawn = vi.fn(async () => {}); + const onSpawned = vi.fn(); + const stock = stockAcpRuntimeOptions({ ...base, spawnAgent: (_input: unknown) => fakeChild(42), onAgentSpawn, processLifecycle: { onSpawned } }); + stock.spawnAgent!({ command: "agent", args: [], options: {} }); + await stock.processLifecycle!.onSpawned!({ ...launch, pid: 7, startedAt: "t0" }); + expect(onAgentSpawn).not.toHaveBeenCalled(); + await stock.processLifecycle!.onSpawned!({ ...launch, pid: 42, startedAt: "t1" }); + expect(onAgentSpawn).toHaveBeenCalledExactlyOnceWith({ pid: 42, startedAt: "t1" }); + expect(onSpawned).toHaveBeenCalledTimes(2); + }); + + it("propagates an admission rejection so ACPX terminates the agent", async () => { + const stock = stockAcpRuntimeOptions({ ...base, spawnAgent: (_input: unknown) => fakeChild(43), onAgentSpawn: async () => { throw new Error("not admitted"); } }); + stock.spawnAgent!({ command: "agent", args: [], options: {} }); + await expect(stock.processLifecycle!.onSpawned!({ ...launch, pid: 43, startedAt: "t" })).rejects.toThrow("not admitted"); + }); +}); + +describe("agent environment", () => { + it("launches with exactly the engine's environment, read at each spawn", () => { + const spawnAgent = vi.fn((_input: unknown) => fakeChild(44)); + let current: Record = { PATH: "/run-1/bin" }; + const stock = stockAcpRuntimeOptions({ ...base, spawnAgent, agentEnvironment: () => current }); + stock.spawnAgent!({ command: "agent", args: [], options: { env: { PATH: "/host/bin", HOST_SECRET: "x" } } }); + expect(spawnAgent).toHaveBeenLastCalledWith({ command: "agent", args: [], options: { env: { PATH: "/run-1/bin" } } }); + // A warm runtime respawns with the current run's environment. + current = { PATH: "/run-2/bin" }; + stock.spawnAgent!({ command: "agent", args: [], options: { env: { PATH: "/host/bin" } } }); + expect(spawnAgent).toHaveBeenLastCalledWith({ command: "agent", args: [], options: { env: { PATH: "/run-2/bin" } } }); + }); + + it("refuses host ACPX auth credentials that ACPX would apply to every agent", () => { + expect(() => assertNoHostAcpxAuthEnvironment({ PATH: "/bin" })).not.toThrow(); + expect(() => assertNoHostAcpxAuthEnvironment({ ACPX_AUTH_OPENAI: "k", acpx_auth_x: "y" })).toThrow(/ACPX_AUTH_OPENAI, acpx_auth_x/); + }); +}); + +describe("Windows agent command split", () => { + it("keeps backslashes and groups quoted words", () => { + expect(splitWindowsAgentCommand('"C:\\Program Files\\nodejs\\node.exe" C:\\agents\\acp.js --flag \'a b\'')) + .toEqual(["C:\\Program Files\\nodejs\\node.exe", "C:\\agents\\acp.js", "--flag", "a b"]); + }); + + it("rejects an unterminated quote or an empty command", () => { + expect(() => splitWindowsAgentCommand('"C:\\agent')).toThrow(/unterminated quote/); + expect(() => splitWindowsAgentCommand(" ")).toThrow(/empty command/); + expect(() => splitWindowsAgentCommand('"" x')).toThrow(/empty command/); + }); +}); + +describe("typed terminal session failures", () => { + const meta = (failure: unknown) => ({ jetbrains: { air: { version: 1, sessionFailure: failure } } }); + + it("reads typed errors and maps unknown categories", () => { + expect(typedTerminalSessionFailure(meta({ severity: "error", category: "limit", title: "t", details: "d" }))).toEqual({ category: "limit", title: "t", details: "d" }); + expect(typedTerminalSessionFailure(meta({ severity: "error", category: "other" }))).toEqual({ category: "unknown" }); + for (const value of [undefined, {}, meta({ severity: "warning", category: "limit" }), { jetbrains: { air: { version: 0, sessionFailure: { severity: "error" } } } }]) { + expect(typedTerminalSessionFailure(value)).toBeNull(); + } + }); + + it("fails a normally stopped turn and leaves other results unchanged", () => { + const onFailure = vi.fn(); + expect(typedTerminalTurnResult({ status: "completed", _meta: meta({ severity: "error", category: "service" }) }, onFailure)) + .toEqual({ status: "failed", error: { message: "ACP agent reported a terminal service failure.", code: "RUNTIME" } }); + expect(onFailure).toHaveBeenCalledExactlyOnceWith({ category: "service" }); + const completed = { status: "completed" as const, stopReason: "end_turn" }; + expect(typedTerminalTurnResult(completed, onFailure)).toBe(completed); + const failed = { status: "failed" as const, error: { message: "x" } }; + expect(typedTerminalTurnResult(failed, onFailure)).toBe(failed); + }); +}); diff --git a/packages/adapter-utils/src/acpx-engine/stock-runtime-options.ts b/packages/adapter-utils/src/acpx-engine/stock-runtime-options.ts new file mode 100644 index 00000000000..541f9b752b0 --- /dev/null +++ b/packages/adapter-utils/src/acpx-engine/stock-runtime-options.ts @@ -0,0 +1,108 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import type { AcpRuntimeOptions } from "acpx/runtime"; + +// A type alias, not an interface, so callers can treat options as plain records. +export type AcpxAgentSpawnHooks = { + /** Host-only child cwd (the remote relay); ACP still advertises `cwd`. */ + spawnCwd?: string; + /** Observes agent stderr chunks; callback failures are ignored. */ + onAgentStderr?: (chunk: string) => void; + /** Admits the agent process, not ACPX's own command probes; a rejection terminates it. */ + onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise | void; + /** + * The agent's complete environment, read at each spawn. It replaces the one + * ACPX derives from this process, so the agent never inherits host variables. + */ + agentEnvironment?: () => Readonly>; +}; + +/** + * Express the engine's spawn hooks with stock ACPX 0.22 options. ACPX calls + * `spawnAgent` only for the agent process, so the agent's pid is known here; + * `processLifecycle.onSpawned` also fires for command probes and is filtered + * to that pid before admission. Its rejection terminates the child, as before. + */ +export function stockAcpRuntimeOptions( + options: T, +): Omit { + const { spawnCwd, onAgentStderr, onAgentSpawn, agentEnvironment, ...stock } = options; + if (spawnCwd === undefined && !onAgentStderr && !onAgentSpawn && !agentEnvironment) return stock; + const agentPids = new Set(); + const launch = stock.spawnAgent + ?? ((input: Parameters>[0]): ChildProcess => spawn(input.command, [...input.args], input.options)); + return { + ...stock, + spawnAgent: (input) => { + const child = launch({ ...input, options: { + ...input.options, + ...(spawnCwd === undefined ? {} : { cwd: spawnCwd }), + ...(agentEnvironment ? { env: { ...agentEnvironment() } } : {}), + } }); + if (typeof child.pid === "number") agentPids.add(child.pid); + if (onAgentStderr) { + child.stderr?.on("data", (chunk: Buffer | string) => { + try { + onAgentStderr(String(chunk)); + } catch { + // Observation never changes the agent's lifecycle. + } + }); + } + return child; + }, + ...(onAgentSpawn ? { + processLifecycle: { + ...stock.processLifecycle, + onSpawned: async (process) => { + await stock.processLifecycle?.onSpawned?.(process); + if (agentPids.has(process.pid)) await onAgentSpawn({ pid: process.pid, startedAt: process.startedAt }); + }, + }, + } : {}), + }; +} + +/** + * ACPX 0.22 rejects raw command strings on Windows and only launches argv + * arrays there. Split a configured command line with Windows rules: quotes + * group words and backslashes are path separators, never escapes. + */ +export function splitWindowsAgentCommand(value: string): string[] { + const parts: string[] = []; + let current = ""; + let quote: string | null = null; + let hasPart = false; + for (const ch of value) { + if (quote) { + if (ch === quote) quote = null; + else current += ch; + hasPart = true; + } else if (ch === "\"" || ch === "'") { + quote = ch; + hasPart = true; + } else if (/\s/.test(ch)) { + if (hasPart) parts.push(current); + current = ""; + hasPart = false; + } else { + current += ch; + hasPart = true; + } + } + if (quote) throw new Error("Invalid agent command: unterminated quote"); + if (hasPart) parts.push(current); + if (parts.length === 0 || parts[0] === "") throw new Error("Invalid agent command: empty command"); + return parts; +} + +/** + * ACPX reads `ACPX_AUTH_*` credentials from this process's environment for + * agent authentication. In the shared server those would be host credentials + * applied to every agent, so refuse to start rather than use them. + */ +export function assertNoHostAcpxAuthEnvironment(environment: NodeJS.ProcessEnv = process.env): void { + const names = Object.keys(environment).filter(name => name.toUpperCase().startsWith("ACPX_AUTH_")); + if (names.length > 0) { + throw new Error(`Remove ${names.join(", ")} from the Paperclip server environment; ACPX would use it to authenticate agents.`); + } +} diff --git a/packages/adapter-utils/src/acpx-engine/terminal-session-failure.ts b/packages/adapter-utils/src/acpx-engine/terminal-session-failure.ts index 8d7c2758adb..00bd059cd11 100644 --- a/packages/adapter-utils/src/acpx-engine/terminal-session-failure.ts +++ b/packages/adapter-utils/src/acpx-engine/terminal-session-failure.ts @@ -99,3 +99,48 @@ export function formatTerminalSessionFailure( if (!diagnostic) return message; return [...new Set([message, diagnostic.title, diagnostic.details].filter(Boolean))].join("\n"); } + +/** Advertised on initialize so ACP agents report terminal failures as typed JetBrains AIR metadata. */ +export function typedSessionFailureClientCapabilities(): Record { + return { _meta: { jetbrains: { air: { version: 1, capabilities: ["sessionFailure"] } } } }; +} + +/** + * Read a typed terminal failure from the prompt response `_meta`, which ACPX + * returns on the turn result. The agent still answers with a normal stop + * reason, so the engine fails the turn itself. + */ +export function typedTerminalSessionFailure(meta: unknown): AcpxTerminalSessionFailure | null { + const object = (value: unknown): Record | null => + value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : null; + const air = object(object(object(meta)?.jetbrains)?.air); + if (!air || !Number.isInteger(air.version) || Number(air.version) < 1) return null; + const failure = object(air.sessionFailure); + if (!failure || failure.severity !== "error") return null; + // Keep complete provider text; sanitizeTerminalSessionFailure redacts before bounding. + return { + category: typeof failure.category === "string" && CATEGORIES.has(failure.category) ? failure.category : "unknown", + ...(typeof failure.title === "string" ? { title: failure.title } : {}), + ...(typeof failure.details === "string" ? { details: failure.details } : {}), + }; +} + +type TurnResult = + | { status: "completed" | "cancelled"; stopReason?: string; _meta?: Record | null } + | { status: "failed"; error: { message: string; code?: string; detailCode?: string; retryable?: boolean } }; + +/** A typed AIR session failure fails the turn even though ACP reported a normal stop. */ +export function typedTerminalTurnResult( + result: T, + onFailure: (failure: AcpxTerminalSessionFailure) => void, +): T | { status: "failed"; error: { message: string; code: string } } { + if (result.status === "failed") return result; + const failure = typedTerminalSessionFailure(result._meta); + if (!failure) return result; + try { + onFailure(failure); + } catch { + // Diagnostics cannot change the failed terminal outcome. + } + return { status: "failed", error: { message: `ACP agent reported a terminal ${failure.category} failure.`, code: "RUNTIME" } }; +} diff --git a/packages/adapter-utils/src/mcp-isolation.integration.test.ts b/packages/adapter-utils/src/mcp-isolation.integration.test.ts index 23b07c093d7..034d4ecd770 100644 --- a/packages/adapter-utils/src/mcp-isolation.integration.test.ts +++ b/packages/adapter-utils/src/mcp-isolation.integration.test.ts @@ -27,7 +27,8 @@ interface McpObservation { tools: string[]; } -type McpServer = NonNullable[number]; +// `mcpServers` also accepts a per-session resolver; this test passes the list form. +type McpServer = Extract, readonly unknown[]>[number]; afterEach(async () => { await Promise.all( diff --git a/packages/adapters/claude-local/src/server/acp.quota.test.ts b/packages/adapters/claude-local/src/server/acp.quota.test.ts index a3c83177f57..4c74d0adc7f 100644 --- a/packages/adapters/claude-local/src/server/acp.quota.test.ts +++ b/packages/adapters/claude-local/src/server/acp.quota.test.ts @@ -1,7 +1,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { afterEach, expect, it, vi } from "vitest"; import { classifyClaudeTerminalSessionFailure, createClaudeAcpExecutor } from "./acp.js"; @@ -12,9 +11,6 @@ const repoRoot = fileURLToPath(new URL("../../../../..", import.meta.url)); const fixture = path.join(repoRoot, "scripts/mcp-fixtures/servers/acp-echo-agent.mjs"); const roots: string[] = []; const now = new Date("2026-07-15T20:00:00.000Z"); -// Exercise both pinned dependency patches through the same real ACP child. -const runnerRequire = createRequire(path.join(repoRoot, "packages/paperclip-runner/package.json")); -const runnerAcpx = await import(runnerRequire.resolve("acpx/runtime")); afterEach(async () => { vi.unstubAllEnvs(); @@ -58,14 +54,12 @@ async function executeFailure( } it.each([ - ["0.12.0", "oneshot"], - ["0.12.0", "persistent"], - ["0.13.1", "oneshot"], - ["0.13.1", "persistent"], -])("waits for a typed Claude quota reset with ACPX %s in %s mode with provider diagnostics", async (version, mode) => { + ["0.22.0", "oneshot"], + ["0.22.0", "persistent"], +])("waits for a typed Claude quota reset with ACPX %s in %s mode with provider diagnostics", async (_version, mode) => { const title = "You've hit your session limit ยท resets 4:30pm (America/Chicago)"; const { result, logs } = await executeFailure( - title, "limit", mode, version === "0.13.1" ? runnerAcpx.createAcpRuntime : undefined, + title, "limit", mode, undefined, ); expect(result).toMatchObject({ exitCode: 1, @@ -85,11 +79,9 @@ it.each([ }); it.each([ - ["0.12.0", "oneshot"], - ["0.12.0", "persistent"], - ["0.13.1", "oneshot"], - ["0.13.1", "persistent"], -])("retains redacted service diagnostics beyond 4 KiB with ACPX %s in %s mode", async (version, mode) => { + ["0.22.0", "oneshot"], + ["0.22.0", "persistent"], +])("retains redacted service diagnostics beyond 4 KiB with ACPX %s in %s mode", async (_version, mode) => { const secret = "opaque-provider-credential-canary"; const inheritedSecret = "opaque-inherited-proxy-canary"; // HTTP_PROXY is inherited by the real ACP launch but has no secret-name hint. @@ -97,7 +89,7 @@ it.each([ const title = "HTTP 529: overloaded_error"; const details = `${"provider context\n".repeat(300)}request_id=req_service_123\nCredential echoed: ${secret}\nInherited credential: ${inheritedSecret}\n at prompt (agent.js:42:7)`; const { result, logs } = await executeFailure( - title, "service", mode, version === "0.13.1" ? runnerAcpx.createAcpRuntime : undefined, + title, "service", mode, undefined, { PROVIDER_SETTING: secret }, details, ); expect(result).toMatchObject({ @@ -130,15 +122,13 @@ it("classifies quota without a reset time for the existing recovery backoff", as }); it.each([ - ["0.12.0", "oneshot"], - ["0.12.0", "persistent"], - ["0.13.1", "oneshot"], - ["0.13.1", "persistent"], -])("recognizes the Claude bridge quota fallback with ACPX %s in %s mode", async (version, mode) => { + ["0.22.0", "oneshot"], + ["0.22.0", "persistent"], +])("recognizes the Claude bridge quota fallback with ACPX %s in %s mode", async (_version, mode) => { // @agentclientprotocol/claude-agent-acp's quota_exhausted fallback title. const title = "The Claude account has no available quota."; const { result, logs } = await executeFailure( - title, "limit", mode, version === "0.13.1" ? runnerAcpx.createAcpRuntime : undefined, + title, "limit", mode, undefined, ); expect(result).toMatchObject({ exitCode: 1, diff --git a/packages/adapters/codex-local/src/server/acp.quota.test.ts b/packages/adapters/codex-local/src/server/acp.quota.test.ts index 4484dff17cf..eefae94ce2c 100644 --- a/packages/adapters/codex-local/src/server/acp.quota.test.ts +++ b/packages/adapters/codex-local/src/server/acp.quota.test.ts @@ -1,7 +1,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { afterEach, expect, it } from "vitest"; import { classifyCodexTerminalSessionFailure, createCodexAcpExecutor } from "./acp.js"; @@ -11,9 +10,6 @@ const repoRoot = fileURLToPath(new URL("../../../../..", import.meta.url)); const fixture = path.join(repoRoot, "scripts/mcp-fixtures/servers/acp-echo-agent.mjs"); const roots: string[] = []; const now = new Date("2026-07-15T20:00:00.000Z"); -// Exercise both pinned dependency patches through the same real ACP child. -const runnerRequire = createRequire(path.join(repoRoot, "packages/paperclip-runner/package.json")); -const runnerAcpx = await import(runnerRequire.resolve("acpx/runtime")); afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); @@ -53,14 +49,12 @@ async function executeFailure( } it.each([ - ["0.12.0", "oneshot"], - ["0.12.0", "persistent"], - ["0.13.1", "oneshot"], - ["0.13.1", "persistent"], -])("waits for a typed Codex quota reset with ACPX %s in %s mode without exposing provider text", async (version, mode) => { + ["0.22.0", "oneshot"], + ["0.22.0", "persistent"], +])("waits for a typed Codex quota reset with ACPX %s in %s mode without exposing provider text", async (_version, mode) => { const title = "You've hit your usage limit for GPT-5. Switch to another model now, or try again at 4:30 PM (America/Chicago)."; const { result, logs } = await executeFailure( - title, "limit", mode, version === "0.13.1" ? runnerAcpx.createAcpRuntime : undefined, + title, "limit", mode, undefined, ); expect(result).toMatchObject({ exitCode: 1, diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index a3993363cd0..1e36fb3121f 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -1,6 +1,6 @@ { "schema": "paperclip.acpx-profiles.v1", - "acpxVersion": "0.13.1", + "acpxVersion": "0.22.0", "driverKind": "acpx_runtime", "protocolVersion": 1, "profiles": { diff --git a/packages/paperclip-runner/docs/durable-recovery.md b/packages/paperclip-runner/docs/durable-recovery.md index 60200f44237..30423e9983e 100644 --- a/packages/paperclip-runner/docs/durable-recovery.md +++ b/packages/paperclip-runner/docs/durable-recovery.md @@ -126,7 +126,10 @@ schema). When it starts a turn, before any provider work, it truncates the file and writes a `received` record. Immediately before ACPX writes the turn's first `session/prompt` request to the provider, it appends a `dispatched` record and fsyncs it. ACPX calls the host's `onAcpMessage("outbound")` hook synchronously -before it writes the bytes, so a failed journal write aborts the prompt write. +before it writes the bytes. ACPX 0.22 ignores errors from that hook, so the port +keeps a failed journal write and throws it from the turn's `assertActive`, which +ACPX calls next, before the write. A failed journal write therefore aborts the +prompt write. The Runner reads the journal only after it acquires the provider lifetime fence, which proves that the sidecar and the provider are gone. @@ -357,8 +360,13 @@ Each provider declares how in `ACPX_RELEASE_ATTESTATIONS` `generate:acpx-profiles` writes the computed digests and `check:acpx-profiles` refuses a stale one, so a digest changes only with its upstream release. Every -ACPX harness binds the shared ACPX layer the same way: saved inputs must match -the current ACPX version, and the provider pack's `contractDigest` binds the +ACPX harness binds the shared ACPX layer the same way. Saved sessions carry +forward across an ACPX upgrade when the new ACPX loads the records the old one +wrote: Runner moves a saved descriptor that names a release in +`CARRIED_ACPX_RELEASES` to the current one, and the session identity keeps its +original ACPX value, so the saved session key and identity record still match. +A session saved by any other ACPX release is refused as +`provider_state_incompatible`. The provider pack's `contractDigest` binds the Runner's ACPX patch with the other Runner contracts. Runner source changes change no provider digest. diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 6694faf3781..47fd7bfed0c 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -85,7 +85,7 @@ "typecheck:browser": "tsc -p tsconfig.browser.json --noEmit", "test": "pnpm run test:typescript && pnpm run test:rust", "test:typescript": "pnpm run test:typescript:prep && vitest run", - "test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node ../../scripts/ci-profile/node-test.mjs test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/materialize-pi-distribution.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/acpx-release-attestations.test.mjs scripts/provider-pack-runtime-tree.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/materialize-kimi-distribution.test.mjs scripts/provision-kimi-package.test.mjs scripts/kimi-metadata-probe.test.mjs scripts/patch-kimi-runtime.test.mjs test/kimi-installed-runtime.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-runtime-dependency-roots.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-provider-node.test.mjs scripts/stage-runner-binary.test.mjs scripts/build-inputs.test.mjs scripts/contracts/contract-fingerprints.test.mjs scripts/provider-pack-executable-shims.test.mjs scripts/controlled-evals/controller.test.mjs scripts/controlled-evals/portable.test.mjs scripts/runner-protocol-testops.test.mjs scripts/runner-protocol-eval-workflow-security.test.mjs && node --test scripts/build-copilot-distribution.test.mjs scripts/copilot-inner-distribution.test.mjs scripts/copilot-smoke-resources.test.mjs scripts/discover-copilot-acp.test.mjs scripts/materialize-copilot-binary.test.mjs scripts/install-copilot-assets.test.mjs scripts/probe-copilot-acp.test.mjs scripts/qualify-copilot-acp.test.mjs test/acpx-message-boundaries-package-contract.test.mjs", + "test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node ../../scripts/ci-profile/node-test.mjs test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-session-carry-forward-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/materialize-pi-distribution.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/acpx-release-attestations.test.mjs scripts/provider-pack-runtime-tree.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/materialize-kimi-distribution.test.mjs scripts/provision-kimi-package.test.mjs scripts/kimi-metadata-probe.test.mjs scripts/patch-kimi-runtime.test.mjs test/kimi-installed-runtime.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-runtime-dependency-roots.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-provider-node.test.mjs scripts/stage-runner-binary.test.mjs scripts/build-inputs.test.mjs scripts/contracts/contract-fingerprints.test.mjs scripts/provider-pack-executable-shims.test.mjs scripts/controlled-evals/controller.test.mjs scripts/controlled-evals/portable.test.mjs scripts/runner-protocol-testops.test.mjs scripts/runner-protocol-eval-workflow-security.test.mjs && node --test scripts/build-copilot-distribution.test.mjs scripts/copilot-inner-distribution.test.mjs scripts/copilot-smoke-resources.test.mjs scripts/discover-copilot-acp.test.mjs scripts/materialize-copilot-binary.test.mjs scripts/install-copilot-assets.test.mjs scripts/probe-copilot-acp.test.mjs scripts/qualify-copilot-acp.test.mjs test/acpx-message-boundaries-package-contract.test.mjs", "test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs", "test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace", "test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider", @@ -180,7 +180,7 @@ "@agentclientprotocol/claude-agent-acp": "0.73.0", "@agentclientprotocol/codex-acp": "1.6.2", "@openai/codex": "0.160.0", - "acpx": "0.13.1", + "acpx": "0.22.0", "ajv": "^8.20.0", "json-schema-to-ts": "^3.1.1", "opencode-ai": "1.18.34", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 7b610e1572f..74ab1f884cd 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -294,7 +294,22 @@ fn only_registered_asset_text_changed( .is_some_and(|(current_prefix, prior_prefix)| current_prefix == prior_prefix) } +// Earlier ACPX releases whose saved sessions this build continues. The current +// ACPX loads the session records they wrote, so a saved descriptor naming one +// is moved to the current release. The launch profile rebind and validation +// still decide whether that session resumes. +const CARRIED_ACPX_RELEASES: &[&str] = &["0.13.1"]; + impl AcpxProviderDescriptor { + fn carry_forward_acpx_release(&mut self) { + if self.acpx_version == self.provider_version + && CARRIED_ACPX_RELEASES.contains(&self.acpx_version.as_str()) + { + self.acpx_version = QUALIFIED_ACPX_VERSION.to_owned(); + self.provider_version = QUALIFIED_ACPX_VERSION.to_owned(); + } + } + // Whether this build qualifies the release that the descriptor names. fn names_qualified_release(&self) -> bool { acpx_release_profile(&self.agent).is_some_and(|expected| { @@ -1189,6 +1204,7 @@ impl AcpxCommandExecutor { let mut state: AcpxDurableState = serde_json::from_slice(&bytes).map_err(|error| { DurableRunnerError::invalid(format!("ACPX provider state is malformed: {error}")) })?; + state.descriptor.carry_forward_acpx_release(); let launch_profile_digest = self.restore_launch_profile_digest(&state)?; if state.launch_profile_digest != launch_profile_digest && self.rebinds_settled_session(&state) @@ -1912,7 +1928,7 @@ impl AcpxCommandExecutor { "status": if resumed { "resumed" } else { "started" }, "provider": "acpx", "driver": "acpx_runtime", - "providerVersion": "0.13.1", + "providerVersion": QUALIFIED_ACPX_VERSION, "providerSessionId": identity.acpx_record_id, "sessionId": identity.agent_session_id, "processId": process_id, @@ -2563,7 +2579,9 @@ impl AcpxCommandExecutor { .map_err(|error| { DurableRunnerError::invalid(format!("ACPX runtime response failed: {error}")) })?; - // The sidecar ACK proves delivery, not durable controller settlement. + // The sidecar ACK proves the selected response was handed to ACPX as + // the provider reply (a permission reply is matched to its exact + // outbound message), not durable controller settlement. // Retain that fact in the same atomic state write that retires the // pending request. A crash before this write expires the old request; // a crash after it replays this event, never the provider response. @@ -6774,4 +6792,60 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } } + + // The previous release pinned ACPX 0.13.1. A session it saved continues: + // the next run attaches to a settled session, and a deploy that lands + // mid-turn takes the ordinary lost-turn recovery. + #[cfg(unix)] + #[test] + fn a_settled_session_saved_by_the_previous_acpx_release_continues() { + let (directory, config, payload, saved) = + settled_session_after_runner_upgrade("previous-acpx-settled"); + let state_path = directory.join(ACPX_PROVIDER_STATE_FILE); + let mut prior: AcpxDurableState = serde_json::from_slice(&saved).unwrap(); + prior.descriptor.acpx_version = "0.13.1".into(); + prior.descriptor.provider_version = "0.13.1".into(); + fs::write(&state_path, serde_json::to_vec(&prior).unwrap()).unwrap(); + + let mut recovered = AcpxCommandExecutor::with_runner_config(&directory, &config); + recovered + .restore() + .expect("a settled 0.13.1 session survives the ACPX upgrade"); + recovered + .attach_run(&payload) + .expect("the next run attaches the same conversation"); + let attached: AcpxDurableState = + serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + assert_eq!(attached.descriptor.acpx_version, QUALIFIED_ACPX_VERSION); + assert_eq!(attached.descriptor.provider_version, QUALIFIED_ACPX_VERSION); + assert_eq!(attached.descriptor.run_id, "run-2"); + assert_eq!(attached.lifecycle, "suspended"); + assert_eq!(attached.identity, prior.identity); + fs::remove_dir_all(directory).unwrap(); + } + + #[cfg(unix)] + #[test] + fn a_turn_interrupted_by_the_acpx_upgrade_takes_lost_turn_recovery() { + let (directory, config, state_path) = + interrupted_turn_after_runner_upgrade("previous-acpx-mid-turn"); + let mut prior: AcpxDurableState = + serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + prior.descriptor.acpx_version = "0.13.1".into(); + prior.descriptor.provider_version = "0.13.1".into(); + fs::write(&state_path, serde_json::to_vec(&prior).unwrap()).unwrap(); + + let mut recovered = AcpxCommandExecutor::with_runner_config(&directory, &config); + recovered + .restore() + .expect("a 0.13.1 turn takes the same lost-turn recovery as a same-release restart"); + let restored: AcpxDurableState = + serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + assert_eq!(restored.descriptor.acpx_version, QUALIFIED_ACPX_VERSION); + assert_eq!(restored.identity, prior.identity); + // The fixture's provider cannot start, so recovery takes the ordinary + // close path of a lost turn instead of failing the restore. + assert!(restored.active_turn_id.is_none()); + fs::remove_dir_all(directory).unwrap(); + } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index b72c57ab6f4..11ccacdf6f3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -1,5 +1,5 @@ // Generated from acpx-profiles.json and cursor-distributions.json. Do not edit. -pub(crate) const QUALIFIED_ACPX_VERSION: &str = "0.13.1"; +pub(crate) const QUALIFIED_ACPX_VERSION: &str = "0.22.0"; pub(crate) const ACPX_DRIVER_KIND: &str = "acpx_runtime"; #[derive(Debug)] diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_park_preparation.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_park_preparation.rs index c1fe9911b63..86614158302 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_park_preparation.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_park_preparation.rs @@ -125,8 +125,8 @@ impl Harness { }; let descriptor = json!({ "kind": "acpx", "provider": "acpx", "driver": "acpx_runtime", - "providerVersion": "0.13.1", "agent": "claude", "model": "explicit-model", - "acpxVersion": "0.13.1", "agentServerPackage": profile["agentServerPackage"], + "providerVersion": "0.22.0", "agent": "claude", "model": "explicit-model", + "acpxVersion": "0.22.0", "agentServerPackage": profile["agentServerPackage"], "agentServerVersion": profile["agentServerVersion"], "agentRuntimePackage": profile["agentRuntimePackage"], "agentRuntimeVersion": profile["agentRuntimeVersion"], "commandDigest": digest, "sidecarCommand": sidecar, "sidecarArgs": args, "runtimeDirectory": fixture.0, diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_park_readiness.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_park_readiness.rs index 7bb49534589..5f00bdaef59 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_park_readiness.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_park_readiness.rs @@ -89,8 +89,8 @@ fn fixture( }; let descriptor = json!({ "kind": "acpx", "provider": "acpx", "driver": "acpx_runtime", - "providerVersion": "0.13.1", "agent": agent, "model": "explicit-model", - "acpxVersion": "0.13.1", "agentServerPackage": profile["agentServerPackage"], + "providerVersion": "0.22.0", "agent": agent, "model": "explicit-model", + "acpxVersion": "0.22.0", "agentServerPackage": profile["agentServerPackage"], "agentServerVersion": profile["agentServerVersion"], "agentRuntimePackage": profile["agentRuntimePackage"], "agentRuntimeVersion": profile["agentRuntimeVersion"], "commandDigest": digest, "sidecarCommand": sidecar, "sidecarArgs": args, "runtimeDirectory": fixture.0, diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_warm_attachment.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_warm_attachment.rs index d6163af3de7..78c5cebb29b 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_warm_attachment.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_warm_attachment.rs @@ -83,8 +83,8 @@ fn checkpoints_the_sidecar_and_rebinds_consecutive_warm_runs_before_accepting_wo }; let mut descriptor = json!({ "kind": "acpx", "provider": "acpx", "driver": "acpx_runtime", - "providerVersion": "0.13.1", "agent": "codex", "model": "gpt-5.6-sol", - "acpxVersion": "0.13.1", "agentServerPackage": "@agentclientprotocol/codex-acp", + "providerVersion": "0.22.0", "agent": "codex", "model": "gpt-5.6-sol", + "acpxVersion": "0.22.0", "agentServerPackage": "@agentclientprotocol/codex-acp", "agentServerVersion": "1.6.2", "agentRuntimePackage": "@openai/codex", "agentRuntimeVersion": "0.160.0", "commandDigest": PROFILE_DIGEST, "sidecarCommand": sidecar, "sidecarArgs": args, "runtimeDirectory": fixture.0, @@ -243,8 +243,8 @@ fn reopens_the_conversation_for_a_turn_after_a_suspension_stop() { }; let descriptor = json!({ "kind": "acpx", "provider": "acpx", "driver": "acpx_runtime", - "providerVersion": "0.13.1", "agent": "codex", "model": "gpt-5.6-sol", - "acpxVersion": "0.13.1", "agentServerPackage": "@agentclientprotocol/codex-acp", + "providerVersion": "0.22.0", "agent": "codex", "model": "gpt-5.6-sol", + "acpxVersion": "0.22.0", "agentServerPackage": "@agentclientprotocol/codex-acp", "agentServerVersion": "1.6.2", "agentRuntimePackage": "@openai/codex", "agentRuntimeVersion": "0.160.0", "commandDigest": PROFILE_DIGEST, "sidecarCommand": sidecar, "sidecarArgs": args, "runtimeDirectory": fixture.0, @@ -361,8 +361,8 @@ fn restores_the_conversation_after_losing_an_active_turn_to_a_runner_restart() { }; let descriptor = json!({ "kind": "acpx", "provider": "acpx", "driver": "acpx_runtime", - "providerVersion": "0.13.1", "agent": "codex", "model": "gpt-5.6-sol", - "acpxVersion": "0.13.1", "agentServerPackage": "@agentclientprotocol/codex-acp", + "providerVersion": "0.22.0", "agent": "codex", "model": "gpt-5.6-sol", + "acpxVersion": "0.22.0", "agentServerPackage": "@agentclientprotocol/codex-acp", "agentServerVersion": "1.6.2", "agentRuntimePackage": "@openai/codex", "agentRuntimeVersion": "0.160.0", "commandDigest": PROFILE_DIGEST, "sidecarCommand": sidecar, "sidecarArgs": args, "runtimeDirectory": fixture.0, @@ -504,8 +504,8 @@ fn restores_a_turn_lost_to_a_runner_upgrade_under_the_new_launch_profile() { }; let descriptor = json!({ "kind": "acpx", "provider": "acpx", "driver": "acpx_runtime", - "providerVersion": "0.13.1", "agent": "codex", "model": "gpt-5.6-sol", - "acpxVersion": "0.13.1", "agentServerPackage": "@agentclientprotocol/codex-acp", + "providerVersion": "0.22.0", "agent": "codex", "model": "gpt-5.6-sol", + "acpxVersion": "0.22.0", "agentServerPackage": "@agentclientprotocol/codex-acp", "agentServerVersion": "1.6.2", "agentRuntimePackage": "@openai/codex", "agentRuntimeVersion": "0.160.0", "commandDigest": PROFILE_DIGEST, "sidecarCommand": sidecar, "sidecarArgs": args, "runtimeDirectory": fixture.0, @@ -634,8 +634,8 @@ fn a_lost_cancellation_reply_still_settles_the_turn_and_suspends_the_runner() { }; let descriptor = json!({ "kind": "acpx", "provider": "acpx", "driver": "acpx_runtime", - "providerVersion": "0.13.1", "agent": "codex", "model": "gpt-5.6-sol", - "acpxVersion": "0.13.1", "agentServerPackage": "@agentclientprotocol/codex-acp", + "providerVersion": "0.22.0", "agent": "codex", "model": "gpt-5.6-sol", + "acpxVersion": "0.22.0", "agentServerPackage": "@agentclientprotocol/codex-acp", "agentServerVersion": "1.6.2", "agentRuntimePackage": "@openai/codex", "agentRuntimeVersion": "0.160.0", "commandDigest": PROFILE_DIGEST, "sidecarCommand": sidecar, "sidecarArgs": args, "runtimeDirectory": fixture.0, diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index a8922c17568..1f4c9c48d57 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -454,10 +454,10 @@ fn prepare_payload_with_mode(directory: &Path, agent: &str, mode: &str) -> Value "kind": "acpx", "provider": "acpx", "driver": "acpx_runtime", - "providerVersion": "0.13.1", + "providerVersion": "0.22.0", "agent": agent, "model": "explicit-test-model", - "acpxVersion": "0.13.1", + "acpxVersion": "0.22.0", "agentServerPackage": "@agentclientprotocol/codex-acp", "agentServerVersion": "1.6.2", "agentRuntimePackage": runtime_package, diff --git a/packages/paperclip-runner/scripts/build-inputs.test.mjs b/packages/paperclip-runner/scripts/build-inputs.test.mjs index 27402c6cdb3..21a4d785e32 100644 --- a/packages/paperclip-runner/scripts/build-inputs.test.mjs +++ b/packages/paperclip-runner/scripts/build-inputs.test.mjs @@ -73,7 +73,7 @@ async function makeRepo() { "pnpm-lock.yaml": "lockfileVersion: 9\n", "pnpm-workspace.yaml": "packages:\n - packages/*\n", "tsconfig.base.json": "{}\n", - "patches/acpx@0.13.1.patch": "--- a\n+++ b\n", + "patches/acpx@0.22.0.patch": "--- a\n+++ b\n", "patches/pi-acp@0.0.33.patch": "--- c\n+++ d\n", "packages/paperclip-runner/package.json": '{"name":"@paperclipai/paperclip-runner"}\n', "packages/paperclip-runner/src/index.ts": "export const one = 1;\n", @@ -121,7 +121,7 @@ test("the input set covers sources, the eval kernel, root patches and root confi "packages/paperclip-runner/package.json", "packages/paperclip-runner/runner/src/main.rs", "packages/paperclip-runner/src/index.ts", - "patches/acpx@0.13.1.patch", + "patches/acpx@0.22.0.patch", "patches/pi-acp@0.0.33.patch", "pnpm-lock.yaml", "pnpm-workspace.yaml", @@ -178,7 +178,7 @@ test("the digest is stable, ignores outputs and caches, and tracks every kind of await expectChange("a Rust source", { "packages/paperclip-runner/runner/src/main.rs": "fn main() { }\n" }); await expectChange("an eval-kernel source", { "packages/paperclip-eval-kernel/src/index.ts": "export const kernel = 2;\n" }); await expectChange("the pi-acp patch the build copies into dist", { "patches/pi-acp@0.0.33.patch": "--- c\n+++ d\n+e\n" }); - await expectChange("the acpx patch the profile check hashes", { "patches/acpx@0.13.1.patch": "--- a\n+++ b\n+c\n" }); + await expectChange("the acpx patch the profile check hashes", { "patches/acpx@0.22.0.patch": "--- a\n+++ b\n+c\n" }); await expectChange("a new patch", { "patches/brace-expansion@5.0.9.patch": "--- x\n+++ y\n" }); await expectChange("the lockfile", { "pnpm-lock.yaml": "lockfileVersion: 9\nchanged: true\n" }); await expectChange("the base tsconfig", { "tsconfig.base.json": '{"compilerOptions":{}}\n' }); diff --git a/packages/paperclip-runner/scripts/contracts/contract-fingerprints.test.mjs b/packages/paperclip-runner/scripts/contracts/contract-fingerprints.test.mjs index d22ee68cb75..2d0f1cb025f 100644 --- a/packages/paperclip-runner/scripts/contracts/contract-fingerprints.test.mjs +++ b/packages/paperclip-runner/scripts/contracts/contract-fingerprints.test.mjs @@ -128,8 +128,13 @@ test("the Runner's ACPX patch binds the pack for every ACPX harness; other patch const runnerPatch = `patches/acpx@${version}.patch`; assert.notEqual(digestWith(runnerPatch, s => `${s}\n`), baseline); const others = sources.list("patches").filter(path => path.endsWith(".patch") && path !== runnerPatch); - assert.ok(others.some(path => path.startsWith("patches/acpx@")), "another package pins another ACPX version"); for (const path of others) assert.equal(digestWith(path, s => `${s}\n`), baseline, path); + // A patch for another ACPX version (another package's pin) never binds the pack. + const otherAcpx = "patches/acpx@0.0.0-other.patch"; + assert.equal(packContractDigest(root, { + read: p => p === otherAcpx ? "--- a\n+++ b\n" : sources.read(p), + list: dir => dir === "patches" ? [...sources.list(dir), otherAcpx] : sources.list(dir), + }), baseline); // A build context without the Runner's patch fails instead of dropping the contract. assert.throws(() => packContractDigest(root, { read: p => p === runnerPatch ? null : sources.read(p), list: dir => sources.list(dir).filter(p => p !== runnerPatch) }), /Registered contract source is missing: acpx-patch/); diff --git a/packages/paperclip-runner/scripts/patch-kimi-runtime.test.mjs b/packages/paperclip-runner/scripts/patch-kimi-runtime.test.mjs index ce4bbfd1be8..377a5f371e8 100644 --- a/packages/paperclip-runner/scripts/patch-kimi-runtime.test.mjs +++ b/packages/paperclip-runner/scripts/patch-kimi-runtime.test.mjs @@ -252,6 +252,8 @@ test("real pinned Kimi and ACPX exchange instructions/tools/denial/errors with a createRequire(cli), module, module.exports, cli, dirname(cli)); compiledFactory = module.exports; } + // Mirrors the runtime adapter: the exact outbound permission reply proves delivery. + const { createAcpxPermissionReplyConfirmations } = await tsImport("../src/drivers/acpx/interaction-delivery.ts", import.meta.url); const [{ verifyKimiInstallation }, { QUALIFIED_ACPX_PROFILES }, { kimiManagedConfiguration }, instructionModule, { createKimiMessageProjection }, { normalizeAcpxPermission }, environmentModule, { configuredEnvironmentProjection, CONFIGURED_ENVIRONMENT_KEYS }] = await Promise.all([ tsImport("../src/drivers/acpx/kimi-installation.ts", import.meta.url), tsImport("../src/drivers/acpx/qualified-profiles.ts", import.meta.url), @@ -421,13 +423,16 @@ test("real pinned Kimi and ACPX exchange instructions/tools/denial/errors with a const fence = compiledFactory ? await compiledFactory.acquireAcpxProviderLifetimeLease({ agentHomeDirectory: home }) : undefined; const owner = { lease, fence, children: [], completions: [], handle: undefined, runtime: undefined, closed: false }; owners.push(owner); + const replies = createAcpxPermissionReplyConfirmations(); owner.runtime = createAcpRuntime({ cwd: workspace, sessionStore: store, agentRegistry: { resolve: () => "paperclip-verified-kimi-command", list: () => ["kimi"] }, mcpServers: [{ type: "http", name: "peer", url: `${endpoint}/mcp`, headers: [] }], permissionMode: "approve-all", permissionPolicy: { defaultAction: "escalate" }, timeoutMs: 15_000, protocolGuardFactory: () => admission.createGuard(), - onAcpMessage: (direction, message) => nativeMessages.push({ owner, direction, message }), - onPermissionRequest: async (request, context) => { + onAcpMessage: (direction, message) => { replies.observe(direction, message); nativeMessages.push({ owner, direction, message }); }, + onPermissionRequest: async (request, stockContext) => { + const reply = replies.bind(request, stockContext.signal); + const decision = await (async (context) => { permissions.push(request.raw); assert.ok(context.responseDelivery instanceof Promise); assert.ok(request.raw.options.every(option => option.kind !== "allow_always")); @@ -470,6 +475,9 @@ test("real pinned Kimi and ACPX exchange instructions/tools/denial/errors with a assert.ok(["mcp__peer__echo_peer", "mcp__peer__empty_peer"].includes(request.raw.toolCall.title)); assert.deepEqual(request.raw.toolCall.rawInput, request.raw.toolCall.title === "mcp__peer__echo_peer" ? argumentsObject : {}); return { outcome: "allow_once" }; + })({ ...stockContext, responseDelivery: reply.delivery }); + reply.select(decision); + return decision; }, spawnAgent: () => spawnNative(lease, owner), }); diff --git a/packages/paperclip-runner/src/backends/codex-native-backend.ts b/packages/paperclip-runner/src/backends/codex-native-backend.ts index 630c14f07c3..581e3a0c4b0 100644 --- a/packages/paperclip-runner/src/backends/codex-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-native-backend.ts @@ -1,6 +1,7 @@ import { describeRunnerdMuseDriver } from "../drivers/muse/runnerd-muse-driver.js"; import { createCodexTaskEnvelope } from "../contracts/codex.js"; import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js"; +import { QUALIFIED_ACPX_VERSION } from "../drivers/acpx/generated-profiles.js"; import { NATIVE_EXECUTION_INPUT_SCHEMA, NATIVE_EXECUTION_INPUT_SCHEMA_V7, NATIVE_EXECUTION_INPUT_SCHEMA_V9, NATIVE_EXECUTION_INPUT_SCHEMA_V10 } from "../contracts/native-execution.js"; import type { NativeExecutionInput } from "../contracts/native-execution.js"; import type { PersistedHarnessSession } from "../contracts/harness-driver.js"; @@ -101,7 +102,7 @@ function transportDriverIdentity(input: NativeExecutionInput): { return { kind: "acpx_runtime", displayName: `${ACPX_CAPABILITY_PROFILES[input.provider.agent].displayName} via ACPX`, - version: "0.13.1", + version: QUALIFIED_ACPX_VERSION, }; default: throw new Error( diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index 6886e845d0a..008d9249cf6 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -752,7 +752,7 @@ describe("native backend factory", () => { await expect(backend.descriptor()).resolves.toMatchObject({ kind: "runner", name: "acpx_runtime", - version: "0.13.1", + version: "0.22.0", capabilities: { resume: true, interruption: true, @@ -776,7 +776,7 @@ describe("native backend factory", () => { await expect(backend.descriptor()).resolves.toMatchObject({ name: "acpx_runtime", - version: "0.13.1", + version: "0.22.0", capabilities: { steering: false, resume: true, @@ -802,7 +802,7 @@ describe("native backend factory", () => { const backend = createNativeSessionBackend(input, { codexTransportFactory: () => { throw new Error("descriptor must not launch the transport"); }, }); - await expect(backend.descriptor()).resolves.toMatchObject({ name: "acpx_runtime", version: "0.13.1" }); + await expect(backend.descriptor()).resolves.toMatchObject({ name: "acpx_runtime", version: "0.22.0" }); }); it.each(["claude" as const])( @@ -814,7 +814,7 @@ describe("native backend factory", () => { await expect(backend.descriptor()).resolves.toMatchObject({ name: "acpx_runtime", - version: "0.13.1", + version: "0.22.0", }); }, ); @@ -825,7 +825,7 @@ describe("native backend factory", () => { const profile = resolveQualifiedAcpxProfile("pi", "custom/explicit-test-model"); Object.assign(input.provider, { agent: "pi", model: profile.qualificationModel, piThinkingLevel: "low", profile }); const backend = createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime" }); - await expect(backend.descriptor()).resolves.toMatchObject({ name: "acpx_runtime", version: "0.13.1" }); + await expect(backend.descriptor()).resolves.toMatchObject({ name: "acpx_runtime", version: "0.22.0" }); }); it.each(["copilot"] as const)("rejects unqualified %s direct execution even with an exact persisted profile", agent => { diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts index d761f886c2e..c24f274c567 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -214,9 +214,9 @@ describe("qualified ACPX runtime sidecar", () => { const currentTurnId="turn", runtimeTurn={requestId:"request-1"}, openParams={agent}, activity=acpxProfileActivity(agent); ${stripTypeScriptTypes(source.slice(start, end))} `).bind(null, acpxProfileActivity, validateAcpxRichEvent, (...args: unknown[]) => emitted.push(args)); - const before = { promptMessageIds: [], requestTokenUsage: {} }; - const after = { lastRequestId: "request-1", promptMessageIds: ["prompt-1"], requestTokenUsage: {}, cursorPromptUsage: { - request_id: "request-1", prompt_message_id: "prompt-1", receipt: { + const before = { requestTokenUsage: {} }; + const after = { lastRequestId: "request-1", requestTokenUsage: {}, cursorPromptReceipt: { + request_id: "request-1", receipt: { schema: "paperclip.cursor.native-usage.v1", source: "native_turn_ended", promptId: "12345678-1234-1234-1234-123456789abc", completeness: "partial", reasons: ["native_counter_semantics_unverified"], observations: [], limits: { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 }, truncated: false, }, diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index f372b66d618..d9c17ddfc73 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -72,7 +72,7 @@ import { frameAcpxToolClassification, parseAcpxSidecarRequest, record, - sanitizeAcpxPlanEntries, + acpxPlanSnapshot, stringifyAcpxSidecarFrame, text, type AcpxExpectedSessionIdentity, @@ -1208,12 +1208,8 @@ function boundRuntimeEventForNormalization( function sanitizeRuntimeEvent(event: PiProjectedMessageEvent & KimiProjectedMessageEvent): Record { const runtimeType = text(record(event).type); - if (runtimeType === "plan") { - return { - type: "plan", - entries: sanitizeAcpxPlanEntries(record(event).entries), - }; - } + const planEntries = acpxPlanSnapshot(event); + if (planEntries) return { type: "plan", entries: planEntries }; const statusNotice = claudeStatusBannerNotice(event); if (statusNotice) return statusNotice; if (event.type === "text_delta") { diff --git a/packages/paperclip-runner/src/conformance/local-provider-fault-matrix.test.ts b/packages/paperclip-runner/src/conformance/local-provider-fault-matrix.test.ts index f69d58d8acb..3f50f5ffaee 100644 --- a/packages/paperclip-runner/src/conformance/local-provider-fault-matrix.test.ts +++ b/packages/paperclip-runner/src/conformance/local-provider-fault-matrix.test.ts @@ -66,7 +66,7 @@ const RUNNERD_FACADE_PROFILES = [ profile: { driverKind: "acpx_runtime", protocolVersion: 1, - acpxVersion: "0.13.1", + acpxVersion: "0.22.0", agent: "claude", agentProfileVersion: 1, agentServerPackage: "@agentclientprotocol/claude-agent-acp", @@ -88,7 +88,7 @@ const RUNNERD_FACADE_PROFILES = [ profile: { driverKind: "acpx_runtime", protocolVersion: 1, - acpxVersion: "0.13.1", + acpxVersion: "0.22.0", agent: "codex", agentProfileVersion: 1, agentServerPackage: "@agentclientprotocol/codex-acp", diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 2d80642705f..615101b0e28 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -394,7 +394,7 @@ describe("NativeExecutionInputV1", () => { profile: { driverKind: "acpx_runtime", protocolVersion: 1, - acpxVersion: "0.13.1", + acpxVersion: "0.22.0", agent: "pi", agentProfileVersion, agentServerPackage: "pi-acp", @@ -664,7 +664,7 @@ describe("native task context ownership", () => { { driverKind: "opencode_server", provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731", permissionMode: "deny" } }, { driverKind: "acpx_runtime", provider: { kind: "acpx", agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", permissionMode: "deny-all", - profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.13.1", agent: "pi", agentProfileVersion: 1, + profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.22.0", agent: "pi", agentProfileVersion: 1, agentServerPackage: "pi-acp", agentServerVersion: "0.0.33", agentRuntimePackage: "@earendil-works/pi-coding-agent", agentRuntimeVersion: "0.84.2", commandDigest: `sha256:${"a".repeat(64)}` }, } }, @@ -700,7 +700,7 @@ describe("native task context ownership", () => { const current = currentInput(); const provider = { kind: "acpx", agent: "codex", model: "gpt-5.6-sol", permissionMode: "approve-all", mode: "architect", - profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.13.1", agent: "codex", agentProfileVersion: 3, + profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.22.0", agent: "codex", agentProfileVersion: 3, agentServerPackage: "@agentclientprotocol/codex-acp", agentServerVersion: "1.6.2", agentRuntimePackage: "@openai/codex", agentRuntimeVersion: "0.160.0", commandDigest: `sha256:${"a".repeat(64)}` }, }; diff --git a/packages/paperclip-runner/src/drivers/acpx/antigravity-admission.ts b/packages/paperclip-runner/src/drivers/acpx/antigravity-admission.ts index 11cee9481d1..c4d98393f2b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/antigravity-admission.ts +++ b/packages/paperclip-runner/src/drivers/acpx/antigravity-admission.ts @@ -161,15 +161,20 @@ function selectionValues(value: Record): Map { } /** Native model options group variants into a family plus thinking level. - * ACPX treats currentValue as a concrete model ID. Project only after the - * native receipt binds the actual initialized variant; keep the native values - * above so subsequent family/thinking changes cannot inherit that authority. + * ACPX treats currentValue as a concrete model ID and accepts only listed + * values. Project only after the native receipt binds the actual initialized + * variant; keep the native values above so subsequent family/thinking changes + * cannot inherit that authority. */ function projectConcreteModel(value: Record, model: string): void { if (!Array.isArray(value.configOptions)) return; for (const entry of value.configOptions) { const option = record(entry); - if (option.id === "model") option.currentValue = model; + if (option.id !== "model") continue; + option.currentValue = model; + if (Array.isArray(option.options) && !option.options.some(choice => record(choice).value === model)) { + option.options = [...option.options, { value: model, name: model }]; + } } } diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts index 3fbcc41260e..0c1873a57ae 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts @@ -98,9 +98,9 @@ describe("Codex ACPX harness driver", () => { it.each(["cursor", "copilot"] as const)("emits partial Cursor metadata only for admitted Cursor, preserving %s settlement", async agent => { const fixture = driverFixture({ agent, model: "explicit-test-model", providerPolicy: { readOnly: true } }, { runtimeEvents: [] }); const session = await fixture.driver.openSession({ runId: "run-native-usage", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); - const before = { promptMessageIds: [], requestTokenUsage: {} }; - const after = { lastRequestId: "provider-turn-1", promptMessageIds: ["prompt-1"], requestTokenUsage: {}, cursorPromptUsage: { - request_id: "provider-turn-1", prompt_message_id: "prompt-1", receipt: { + const before = { requestTokenUsage: {} }; + const after = { lastRequestId: "provider-turn-1", requestTokenUsage: {}, cursorPromptReceipt: { + request_id: "provider-turn-1", receipt: { schema: "paperclip.cursor.native-usage.v1", source: "native_turn_ended", promptId: "12345678-1234-1234-1234-123456789abc", completeness: "partial", reasons: ["native_counter_semantics_unverified"], observations: [{ invocationId: "invocation-1", role: "parent", nativeRun: 1, sequence: 1, counters: { inputTokens: 7 } }], limits: { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 }, truncated: false, @@ -258,7 +258,7 @@ describe("Codex ACPX harness driver", () => { expect(session.turnControlCapabilities?.()).toEqual({ steering: true, queuedFollowUp: true }); const { turnId } = await session.startTurn({ message: { role: "user", text: "Work" } }); const turnInput = fixture.host.startTurn.mock.calls[0]![0]; - const context = { requestId: 0, signal: new AbortController().signal }; + const context = { requestId: 0, signal: new AbortController().signal, responseDelivery: Promise.resolve() }; await expect(turnInput.onExtensionRequest!("provider/request", { sessionId: "agent-1" }, context)).rejects.toThrow(/session mismatch/); await session.steer!({ turnId, correlationId: "control-1", message: { role: "user", text: "Change focus" } }); await session.steer!({ turnId, correlationId: "control-2", mode: "follow_up", message: { role: "user", text: "Then validate" } }); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index 54bf7952842..1efcacd594d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -1928,7 +1928,7 @@ class CodexAcpxSession implements HarnessSession { async #handleElicitation( turnId: string, request: AcpElicitationRequest, - context: AcpElicitationContext, + context: AcpElicitationContext & { responseDelivery?: Promise }, ): Promise { if ( this.#closed || diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts index 72b770ea593..653995bb09f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts @@ -153,7 +153,7 @@ describe("Codex ACPX runtime adapter", () => { _meta: { "x.ai/sessionConfig": { options: [{ id: "high", category: "mode", selected: true }] } } } }); }; vi.mocked(runtime.ensureSession).mockImplementation(async input => { - expect(input.sessionOptions).toMatchObject({ model: "grok-4.7" }); + expect(input.sessionOptions).not.toHaveProperty("model"); if (!restoring) { spawnProvider(); acknowledge(); } return HANDLE; }); @@ -422,7 +422,7 @@ describe("Codex ACPX runtime adapter", () => { await port.close({ reason: "test finished" }); }); - it("installs per-connection policy authority only for Copilot and rejects native commands before a turn starts", async () => { + it("enforces Copilot policy from the observed wire and rejects native commands before a turn starts", async () => { for (const agent of ["copilot", "codex", "claude", "grok"] as const) { const runtime = fakeRuntime(); let created!: AcpRuntimeOptions; @@ -431,15 +431,50 @@ describe("Codex ACPX runtime adapter", () => { const port = await openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; }, }); + // Copilot no longer needs a patched ACPX admission hook; Grok keeps one + // to place task instructions in session/new `_meta.rules`. + if (agent === "grok") { + const guard = created.protocolGuardFactory!(); + const request = { method: "session/new", params: { cwd: "/w", _meta: { systemPrompt: "x" } } }; + guard("outbound", request); + // Grok binds its model at open even when no effort is selected. + expect(request.params._meta).toEqual({ rules: options.systemInstructions ?? "", modelId: options.profile.reportedModelId }); + } else expect(created.protocolGuardFactory).toBeUndefined(); if (agent === "copilot") { - expect(created.protocolGuardFactory).toBeTypeOf("function"); - expect(created.protocolGuardFactory!()).not.toBe(created.protocolGuardFactory!()); expect(() => port.startTurn({ text: "\n/yolo on", requestId: "bad" })).toThrow(/admitted permission policy/); expect(runtime.startTurn).not.toHaveBeenCalled(); - } else expect(created.protocolGuardFactory).toBeUndefined(); + // A load that restores autopilot is observed before ACPX handles it. + created.onAcpMessage!("outbound", { jsonrpc: "2.0", id: 7, method: "session/load", params: { sessionId: "backend-1" } }); + created.onAcpMessage!("inbound", { jsonrpc: "2.0", id: 7, result: { modes: { currentModeId: "autopilot" } } }); + expect(() => port.startTurn({ text: "Perform the task", requestId: "after-violation" })) + .toThrow(expect.objectContaining({ code: "COPILOT_POLICY_VIOLATION" })); + expect(runtime.startTurn).not.toHaveBeenCalled(); + await expect(created.onPermissionRequest!({ sessionId: "backend-1", raw: { sessionId: "backend-1", toolCall: { toolCallId: "tool-1" } }, inferredKind: "execute" } as never, + { signal: new AbortController().signal })).resolves.toEqual({ outcome: "cancel" }); + } await port.close({ reason: "test complete" }); } }); + it("stops Copilot detached work before its permission request can be approved", async () => { + const runtime = fakeRuntime(); + let created!: AcpRuntimeOptions; + const options = openOptions(fakeCommand()); + options.profile = { ...options.profile, agent: "copilot" }; + const port = await openCodexAcpxRuntime(options, { + createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; }, + }); + created.onAcpMessage!("inbound", { jsonrpc: "2.0", method: "session/update", params: { sessionId: "backend-1", update: { + sessionUpdate: "tool_call", toolCallId: "native-detached", kind: "execute", status: "pending", + rawInput: { command: "fixture", mode: "async", detach: true }, + } } }); + created.onAcpMessage!("inbound", { jsonrpc: "2.0", id: 3, method: "session/request_permission", + params: { sessionId: "backend-1", toolCall: { toolCallId: "native-detached", rawInput: { detach: true } } } }); + await expect(created.onPermissionRequest!({ sessionId: "backend-1", raw: { sessionId: "backend-1", toolCall: { toolCallId: "native-detached" } }, inferredKind: "execute" } as never, + { signal: new AbortController().signal })).resolves.toEqual({ outcome: "cancel" }); + expect(() => port.startTurn({ text: "Run the task", requestId: "after-detach" })) + .toThrow(expect.objectContaining({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED" })); + await port.close({ reason: "test complete" }); + }); it.each(["claude"] as const)("rejects approvals without a workflow in preconfigured %s sessions", async (agent) => { const runtime = fakeRuntime(); runtime.setMode = vi.fn().mockResolvedValue(undefined); @@ -460,11 +495,11 @@ describe("Codex ACPX runtime adapter", () => { else expect(sessionOptions?.allowedTools).toBeUndefined(); expect(created.nonInteractivePermissions).toBe("deny"); expect(created.permissionPolicy).toMatchObject({ defaultAction: "deny", autoApprove: undefined, escalate: undefined }); - expect(created.spawnEnvironment!().PAPERCLIP_ACPX_ISOLATED_CONTEXT).toBeUndefined(); - if (agent === "claude") expect(created.spawnEnvironment!()).toMatchObject({ + expect(spawnedAgentEnvironment(created, options.command)!.PAPERCLIP_ACPX_ISOLATED_CONTEXT).toBeUndefined(); + if (agent === "claude") expect(spawnedAgentEnvironment(created, options.command)).toMatchObject({ ANTHROPIC_MODEL: options.profile.reportedModelId, ANTHROPIC_CUSTOM_MODEL_OPTION: options.profile.reportedModelId, }); - else expect(created.spawnEnvironment!().ANTHROPIC_CUSTOM_MODEL_OPTION).toBeUndefined(); + else expect(spawnedAgentEnvironment(created, options.command)!.ANTHROPIC_CUSTOM_MODEL_OPTION).toBeUndefined(); await expect(created.onPermissionRequest!({ sessionId: "backend-1", raw: {}, inferredKind: "execute" } as never, { signal: new AbortController().signal })).resolves.toEqual({ outcome: "reject_once" }); expect(runtime.setMode).not.toHaveBeenCalled(); @@ -487,10 +522,10 @@ describe("Codex ACPX runtime adapter", () => { await expect(created.onPermissionRequest!(request as never, { signal })).resolves.toEqual({ outcome: "reject_once" }); } expect(handler).not.toHaveBeenCalled(); - const responseDelivery = Promise.resolve(); - await expect(created.onPermissionRequest!({ sessionId: "backend-1", raw: { sessionId: "backend-1" }, inferredKind: "edit" } as never, { signal, responseDelivery })).resolves.toEqual({ outcome: "allow_once" }); + await expect(created.onPermissionRequest!({ sessionId: "backend-1", raw: { sessionId: "backend-1" }, inferredKind: "edit" } as never, { signal })).resolves.toEqual({ outcome: "allow_once" }); expect(handler).toHaveBeenCalledOnce(); - expect(handler).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ responseDelivery })); + // The adapter, not ACPX, supplies the delivery receipt. + expect(handler).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ responseDelivery: expect.any(Promise) })); options.permissionMode = "approve-all"; await expect(created.onPermissionRequest!({ sessionId: "forged", raw: {}, inferredKind: "edit" } as never, { signal })).resolves.toEqual({ outcome: "reject_once" }); pending.settle(); await turn.result; await port.close({ reason: "session checks complete" }); @@ -510,7 +545,7 @@ describe("Codex ACPX runtime adapter", () => { })); const turn = port.startTurn({ text: "Write", requestId: "turn-1", onPermissionRequest: handler }); const permission = created.onPermissionRequest!({ sessionId: "backend-1", raw: { sessionId: "backend-1" }, inferredKind: "edit" } as never, - { signal: new AbortController().signal, responseDelivery: Promise.resolve() }); + { signal: new AbortController().signal }); expect(callbackSignal.aborted).toBe(false); const oldMode = options.permissionMode; options.permissionMode = "approve-all"; @@ -615,20 +650,20 @@ describe("Codex ACPX runtime adapter", () => { return runtime; }, }); - expect(created.clientCapabilities).toEqual(options.clientCapabilities); + // Runner always advertises typed AIR failures alongside profile capabilities. + expect(created.clientCapabilities).toEqual({ _meta: { jetbrains: { air: { version: 1, capabilities: ["sessionFailure"] } }, cursor: { test: true } } }); expect(JSON.stringify(vi.mocked(runtime.ensureSession).mock.calls)).not.toContain("clientCapabilities"); const notification = vi.fn(); const request = vi.fn(async (_method, _params, context) => ({ requestId: context.requestId })); const onTerminalSessionFailure = vi.fn(); const admittedFirst = port.startTurn({ text: "First", requestId: "turn-1", onExtensionRequest: request, onExtensionNotification: notification, onTerminalSessionFailure }); // ACPX reports only a generic terminal error; the typed reason must reach the host. - expect(vi.mocked(runtime.startTurn).mock.calls.at(-1)?.[0]).toMatchObject({ onTerminalSessionFailure }); + expect(vi.mocked(runtime.startTurn).mock.calls.at(-1)?.[0]).not.toHaveProperty("onTerminalSessionFailure"); const signal = new AbortController().signal; await expect(created.onExtensionRequest!("cursor/ask_question", { sessionId: "backend-1" }, { requestId: 0, signal })).resolves.toEqual({ requestId: 0 }); const sessionless = { toolCallId: "native-cursor-plan", plan: "Review the complete plan" }; - const responseDelivery = Promise.resolve(); - await expect(created.onExtensionRequest!("cursor/create_plan", sessionless, { requestId: "plan", signal, responseDelivery })).resolves.toEqual({ requestId: "plan" }); - expect(request).toHaveBeenLastCalledWith("cursor/create_plan", { ...sessionless, sessionId: "backend-1" }, expect.objectContaining({ requestId: "plan", responseDelivery })); + await expect(created.onExtensionRequest!("cursor/create_plan", sessionless, { requestId: "plan", signal })).resolves.toEqual({ requestId: "plan" }); + expect(request).toHaveBeenLastCalledWith("cursor/create_plan", { ...sessionless, sessionId: "backend-1" }, expect.objectContaining({ requestId: "plan", responseDelivery: expect.any(Promise) })); expect(sessionless).not.toHaveProperty("sessionId"); await expect(created.onExtensionRequest!("pi/steer", {}, { requestId: 1, signal })).rejects.toThrow("admitted active turn"); await expect(created.onExtensionRequest!("cursor/ask_question", { sessionId: "other" }, { requestId: 1, signal })).rejects.toThrow("admitted active turn"); @@ -656,7 +691,7 @@ describe("Codex ACPX runtime adapter", () => { const pending = pendingExtensionTurn("turn-1"); const runtime = Object.assign(fakeRuntime(), { requestExtension: vi.fn(async (_input: unknown) => ({ accepted: true })) }); vi.mocked(runtime.startTurn).mockReturnValue(pending.turn); - let created!: AcpRuntimeOptions & { onAgentInitialize?: (result: unknown) => void }; + let created!: AcpRuntimeOptions; const options = openOptions(fakeCommand()); options.profile = { ...options.profile, agent: "pi" }; options.piThinkingLevel = "low"; @@ -673,7 +708,7 @@ describe("Codex ACPX runtime adapter", () => { }); const turn = port.startTurn({ text: "Initial", requestId: "turn-1" }); await expect(port.steerActiveTurn!("Update", "turn-1")).rejects.toThrow("supported active turn"); - created.onAgentInitialize!({ agentCapabilities: { _meta: { paperclipPi: { version: 1, steering: true, queuedFollowUp: true } } } }); + initializeOverWire(created, { agentCapabilities: { _meta: { paperclipPi: { version: 1, steering: true, queuedFollowUp: true } } } }); expect(port.steeringCapability!()).toEqual({ steering: true, queuedFollowUp: true }); await expect(port.steerActiveTurn!("Update", "wrong")).rejects.toThrow("supported active turn"); await port.steerActiveTurn!("Update", "turn-1"); @@ -694,13 +729,93 @@ describe("Codex ACPX runtime adapter", () => { await port.close({ reason: "test finished" }); }); + it("keeps the agent's goal capability with its saved capabilities and restores it without reconnecting", async () => { + const goal = { version: 1, controlMethod: "_session/goal", actions: ["set", "pause", "resume", "clear"] }; + const saved: AcpSessionRecord[] = []; + let created!: AcpRuntimeOptions; + const baseRecord = { acpxRecordId: "record-1", acpSessionId: "backend-1", cwd: "/workspace", + agentCapabilities: { loadSession: true, _meta: { vendor: { kept: true } } } } as unknown as AcpSessionRecord; + const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), { + createRegistry: () => registry(), + createStore: () => ({ load: vi.fn(), save: vi.fn(async (record: AcpSessionRecord) => { saved.push(structuredClone(record)); }) }), + createRuntime: (value) => { created = value; return fakeRuntime(); }, + }); + expect(port.goalCapability!()).toBeNull(); + initializeOverWire(created, { agentCapabilities: { loadSession: true }, _meta: { goal } }); + expect(port.goalCapability!()).toEqual(goal); + await created.sessionStore!.save(baseRecord); + expect(saved.at(-1)?.agentCapabilities).toEqual({ loadSession: true, _meta: { vendor: { kept: true }, "paperclip.goalCapability": goal } }); + // ACPX's own in-memory record is not modified. + expect(baseRecord.agentCapabilities).toEqual({ loadSession: true, _meta: { vendor: { kept: true } } }); + initializeOverWire(created, { agentCapabilities: { loadSession: true } }); + await created.sessionStore!.save(saved.at(-1)!); + expect(saved.at(-1)?.agentCapabilities).toEqual({ loadSession: true, _meta: { vendor: { kept: true } } }); + await port.close({ reason: "test finished" }); + + let restored!: AcpRuntimeOptions; + const piOptions = openOptions(fakeCommand()); + piOptions.profile = { ...piOptions.profile, agent: "pi" }; + piOptions.piThinkingLevel = "low"; + const record = { ...baseRecord, agentCapabilities: { loadSession: true, + _meta: { paperclipPi: { version: 1, steering: true, queuedFollowUp: false }, "paperclip.goalCapability": goal } } } as unknown as AcpSessionRecord; + const piRuntime = fakeRuntime(); + // Admit Pi's thinking level the way the earlier Pi test does. + vi.mocked(piRuntime.setConfigOption!).mockImplementation(async input => { + const guard = restored.protocolGuardFactory!(); + guard("outbound", { id: 0, method: "session/load", params: { sessionId: "backend-1" } }); + guard("inbound", { id: 0, result: { modes: { currentModeId: "high" }, configOptions: [{ id: "thought_level", currentValue: "high" }] } }); + guard("outbound", { id: 1, method: "session/set_config_option", params: { sessionId: "backend-1", configId: input.key, value: input.value } }); + guard("inbound", { id: 1, result: { configOptions: [{ id: "thought_level", currentValue: "low" }] } }); + }); + const restoredPort = await openCodexAcpxRuntime(piOptions, { + createRegistry: () => registry(), + createStore: () => ({ load: vi.fn(async () => structuredClone(record)), save: vi.fn() }), + createRuntime: (value) => { restored = value; return piRuntime; }, + }); + // A reused session restores both without the agent reinitializing. + await restored.sessionStore!.load("record-1"); + expect(restoredPort.goalCapability!()).toEqual(goal); + expect(restoredPort.steeringCapability!()).toEqual({ steering: true, queuedFollowUp: false }); + await restoredPort.close({ reason: "test finished" }); + }); + + it("restores goal support that ACPX 0.13.1 saved in its own record field", async () => { + const goal = { version: 1, controlMethod: "_session/goal", actions: ["set", "clear"] }; + // The record ACPX 0.13.1 wrote; ACPX 0.22 drops `agent_goal_capability` when it parses it. + const saved = JSON.parse(await readFile(new URL("../../../test/fixtures/acpx-0.13.1-session-record.json", import.meta.url), "utf8")); + const stateDirectory = await mkdtemp(join(tmpdir(), "acpx-legacy-goal-")); + try { + await mkdir(join(stateDirectory, "sessions")); + await writeFile(join(stateDirectory, "sessions", "carried-key.json"), JSON.stringify(saved)); + const parsed = { acpxRecordId: "carried-key", acpSessionId: "carried-session", cwd: "/workspace", + agentCapabilities: { loadSession: true } } as unknown as AcpSessionRecord; + const saves: AcpSessionRecord[] = []; + let created!: AcpRuntimeOptions; + const options = openOptions(fakeCommand()); + options.stateDirectory = stateDirectory; + const port = await openCodexAcpxRuntime(options, { + createRegistry: () => registry(), + createStore: () => ({ load: vi.fn(async () => structuredClone(parsed)), save: vi.fn(async (record: AcpSessionRecord) => { saves.push(structuredClone(record)); }) }), + createRuntime: (value) => { created = value; return fakeRuntime(); }, + }); + await created.sessionStore!.load("carried-key"); + expect(port.goalCapability!()).toEqual(goal); + // The next save keeps it under the current key, so the old field is read once. + await created.sessionStore!.save(parsed); + expect(saves.at(-1)?.agentCapabilities).toEqual({ loadSession: true, _meta: { "paperclip.goalCapability": goal } }); + await port.close({ reason: "test finished" }); + } finally { + await rm(stateDirectory, { recursive: true, force: true }); + } + }); + it("does not grant steering to Copilot or Cursor when an untrusted initialize result claims Pi support", async () => { const options = openOptions(fakeCommand()); options.profile = { ...options.profile, agent: "copilot" }; const runtime = fakeRuntime(); const port = await openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(), createRuntime: (created) => { - (created as AcpRuntimeOptions & { onAgentInitialize: (result: unknown) => void }).onAgentInitialize({ agentCapabilities: { _meta: { paperclipPi: { version: 1, steering: true, queuedFollowUp: true } } } }); + initializeOverWire(created, { agentCapabilities: { _meta: { paperclipPi: { version: 1, steering: true, queuedFollowUp: true } } } }); return runtime; }, }); @@ -829,11 +944,11 @@ describe("Codex ACPX runtime adapter", () => { expect( JSON.stringify(vi.mocked(runtime.ensureSession).mock.calls[0]?.[0]), ).not.toContain("credential-secret"); - expect(runtimeOptions?.spawnEnvironment?.()).toEqual({ + expect(spawnedAgentEnvironment(runtimeOptions, command)).toEqual({ CODEX_HOME: "/runtime/agent-home", OPENAI_API_KEY: "credential-secret", }); - expect(runtimeOptions?.spawnCwd).toBe("/workspace"); + expect(runtimeOptions).not.toHaveProperty("spawnCwd"); expect(runtimeOptions?.elicitationModes).toEqual(["form"]); expect(await port.identity()).toEqual({ acpxRecordId: "record-1", @@ -866,7 +981,7 @@ describe("Codex ACPX runtime adapter", () => { }, }); - expect(runtimeOptions?.spawnEnvironment?.()).toEqual({ + expect(spawnedAgentEnvironment(runtimeOptions, command)).toEqual({ PATH: "/verified/bin", PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", PAPERCLIP_ACPX_TASK_TOOL_BRIDGE_URL: "", @@ -899,7 +1014,7 @@ describe("Codex ACPX runtime adapter", () => { return fakeRuntime(); }, }); - expect(runtimeOptions?.spawnEnvironment?.()).toEqual({ + expect(spawnedAgentEnvironment(runtimeOptions, options.command)).toEqual({ PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", PAPERCLIP_ACPX_TASK_TOOL_BRIDGE_URL: binding === "runner-owned" ? "http://127.0.0.1:3210/mcp" : "", }); @@ -931,7 +1046,11 @@ describe("Codex ACPX runtime adapter", () => { return runtime; }, }); - expect(command.spawn).toHaveBeenCalledWith(["--stdio"], spawnOptions, { + // The adapter supplies the agent's launch environment, not ACPX's. + expect(command.spawn).toHaveBeenCalledWith(["--stdio"], { ...spawnOptions, env: { + CODEX_HOME: "/runtime/agent-home", + OPENAI_API_KEY: "credential-secret", + } }, { credentialFenceFds: [42, 43], activateCredentialFenceOwner: expect.any(Function), }); @@ -980,8 +1099,8 @@ describe("Codex ACPX runtime adapter", () => { vi.mocked(runtime.startTurn).mockImplementation(input => { const wireId = ++sequence; durableRecord.lastRequestId = input.requestId; - created.onAcpMessage?.("outbound", { id: wireId, method: "session/prompt", params: { sessionId: "backend-1" } }); - created.onAcpMessage?.("inbound", { id: wireId, result: { _meta: { + created.onAcpMessage?.("outbound", { jsonrpc: "2.0", id: wireId, method: "session/prompt", params: { sessionId: "backend-1" } }); + created.onAcpMessage?.("inbound", { jsonrpc: "2.0", id: wireId, result: { _meta: { sessionId: "backend-1", promptId: `prompt-${wireId}`, requestId: `prompt-${wireId}`, usage: { inputTokens: 50, outputTokens: 5, totalTokens: 55, cachedReadTokens: 20, cacheCreationTokens: 0, reasoningTokens: 2, modelCalls: 1, costUsdTicks: wireId === 1 ? 10000000 : 5000000 }, @@ -999,7 +1118,7 @@ describe("Codex ACPX runtime adapter", () => { cost: { amount, currency: "USD", basis: "turn" }, breakdown: { inputTokens: 30, outputTokens: 5, cachedReadTokens: 20, thoughtTokens: 0 }, }); - created.onAcpMessage?.("inbound", { id: sequence, error: { message: "late conflicting response" } }); + created.onAcpMessage?.("inbound", { jsonrpc: "2.0", id: sequence, error: { code: -32603, message: "late conflicting response" } }); expect(await port.getStatus()).toEqual(after); } } finally { await port.close({ reason: "fixture done" }); } @@ -1021,12 +1140,12 @@ describe("Codex ACPX runtime adapter", () => { const turn = port.startTurn({ requestId: "journaled-turn", text: "fixture", dispatchJournal: journal }); expect(order).toEqual(["received", "runtime.startTurn"]); // Other outbound traffic never counts as the prompt. - created.onAcpMessage?.("outbound", { id: 7, method: "session/set_config_option", params: {} }); - created.onAcpMessage?.("outbound", { method: "session/cancel", params: {} }); + created.onAcpMessage?.("outbound", { jsonrpc: "2.0", id: 7, method: "session/set_config_option", params: {} }); + created.onAcpMessage?.("outbound", { jsonrpc: "2.0", method: "session/cancel", params: {} }); expect(journal.markDispatched).not.toHaveBeenCalled(); - created.onAcpMessage?.("outbound", { id: 8, method: "session/prompt", params: { sessionId: "backend-1" } }); + created.onAcpMessage?.("outbound", { jsonrpc: "2.0", id: 8, method: "session/prompt", params: { sessionId: "backend-1" } }); // Only the first prompt of the turn is journaled. - created.onAcpMessage?.("outbound", { id: 9, method: "session/prompt", params: { sessionId: "backend-1" } }); + created.onAcpMessage?.("outbound", { jsonrpc: "2.0", id: 9, method: "session/prompt", params: { sessionId: "backend-1" } }); expect(order).toEqual(["received", "runtime.startTurn", "dispatched"]); pending.settle(); await turn.result; @@ -1044,7 +1163,7 @@ describe("Codex ACPX runtime adapter", () => { try { const turn = port.startTurn({ requestId: "unjournaled-start", text: "fixture", dispatchJournal: { markReceived: () => false, markDispatched } }); - created.onAcpMessage?.("outbound", { id: 1, method: "session/prompt", params: {} }); + created.onAcpMessage?.("outbound", { jsonrpc: "2.0", id: 1, method: "session/prompt", params: {} }); expect(markDispatched).not.toHaveBeenCalled(); pending.settle(); await turn.result; @@ -1063,9 +1182,11 @@ describe("Codex ACPX runtime adapter", () => { markReceived: () => true, markDispatched: () => { throw new Error("ENOSPC: journal write failed"); }, } }); - // ACPX calls this hook before it writes the bytes; a throw rejects that write. - expect(() => created.onAcpMessage?.("outbound", { id: 1, method: "session/prompt", params: {} })) - .toThrow("ENOSPC: journal write failed"); + // ACPX 0.22 ignores observer errors, so the failure surfaces from the + // turn's assertActive, which ACPX calls next, before it writes the bytes. + expect(() => created.onAcpMessage?.("outbound", { jsonrpc: "2.0", id: 1, method: "session/prompt", params: {} })).not.toThrow(); + const assertActive = vi.mocked(runtime.startTurn).mock.calls.at(-1)![0].assertActive!; + expect(assertActive).toThrow("ENOSPC: journal write failed"); pending.settle(); await turn.result; } finally { await port.close({ reason: "fixture done" }); } @@ -1084,7 +1205,7 @@ describe("Codex ACPX runtime adapter", () => { createRuntime: value => { created = value; return runtime; } }); vi.mocked(runtime.startTurn).mockImplementation(input => { durableRecord.lastRequestId = input.requestId; - created.onAcpMessage?.("outbound", { id: 1, method: "session/prompt", params: { sessionId: "backend-1" } }); + created.onAcpMessage?.("outbound", { jsonrpc: "2.0", id: 1, method: "session/prompt", params: { sessionId: "backend-1" } }); return { requestId: input.requestId, promptStarted: Promise.resolve(), events: (async function* () {})(), result, cancel: async () => undefined, closeStream: async () => undefined }; }); @@ -1092,8 +1213,8 @@ describe("Codex ACPX runtime adapter", () => { const before = await port.getStatus(); const turn = port.startTurn({ requestId: "runner-cancelled", text: "fixture" }); await turn[stop]({ reason: "fixture stopped" }); - created.onAcpMessage?.("outbound", { id: 2, method: "session/prompt", params: { sessionId: "backend-1" } }); - created.onAcpMessage?.("inbound", { id: 1, result: { _meta: { + created.onAcpMessage?.("outbound", { jsonrpc: "2.0", id: 2, method: "session/prompt", params: { sessionId: "backend-1" } }); + created.onAcpMessage?.("inbound", { jsonrpc: "2.0", id: 1, result: { _meta: { sessionId: "backend-1", promptId: "prompt-1", requestId: "prompt-1", usage: { inputTokens: 50, outputTokens: 5, totalTokens: 55, cachedReadTokens: 20, cacheCreationTokens: 0, reasoningTokens: 2, modelCalls: 1, costUsdTicks: 10000000 }, @@ -1105,7 +1226,7 @@ describe("Codex ACPX runtime adapter", () => { cost: { amount: 0.001, currency: "USD", basis: "turn" }, breakdown: { inputTokens: 30, outputTokens: 5, cachedReadTokens: 20, thoughtTokens: 0 }, }); - created.onAcpMessage?.("inbound", { id: 1, error: { message: "late conflicting response" } }); + created.onAcpMessage?.("inbound", { jsonrpc: "2.0", id: 1, error: { code: -32603, message: "late conflicting response" } }); expect(await port.getStatus()).toEqual(after); } finally { settle({ status: "completed" }); await port.close({ reason: "fixture done" }); } }); @@ -1121,7 +1242,6 @@ describe("Codex ACPX runtime adapter", () => { request_token_usage: { prompt: { input_tokens: 12, output_tokens: 30 } }, cumulative_cost: { amount: 0.1, currency: "USD" }, messages: [{ User: { id: "prompt", content: [] } }], - cursor_prompt_usage: { request_id: "run:turn-1", prompt_message_id: "prompt", receipt: { diagnostic: "fixture" } }, acpx: { current_model_id: "gpt-5.6-sol", available_models: ["gpt-5.6-sol"], @@ -1145,7 +1265,6 @@ describe("Codex ACPX runtime adapter", () => { requestTokenUsage: { prompt: { input_tokens: 12, output_tokens: 30 } }, usageCost: { amount: 0.1, currency: "USD" }, promptMessageIds: ["prompt"], - cursorPromptUsage: { request_id: "run:turn-1", prompt_message_id: "prompt", receipt: { diagnostic: "fixture" } }, models: { currentModelId: "gpt-5.6-sol", availableModelIds: ["gpt-5.6-sol"], @@ -2188,6 +2307,48 @@ describe("Codex ACPX runtime adapter", () => { } }); + it("fails a turn whose prompt response reports a typed AIR session failure", async () => { + for (const status of ["completed", "cancelled"] as const) { + const runtime = fakeRuntime(); + vi.mocked(runtime.startTurn).mockReturnValue({ + requestId: "turn-1", + promptStarted: Promise.resolve(), + events: { async *[Symbol.asyncIterator]() {} }, + result: Promise.resolve({ status, stopReason: "end_turn", _meta: { jetbrains: { air: { version: 1, + sessionFailure: { severity: "error", category: "limit", title: "Usage limit", details: "Monthly allowance exhausted" } } } } }), + cancel: vi.fn(), + closeStream: vi.fn(), + }); + const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), { + createRegistry: () => registry(), createStore: () => store(), createRuntime: () => runtime, + }); + const onTerminalSessionFailure = vi.fn(); + const turn = port.startTurn({ text: "Complete the task.", requestId: "turn-1", onTerminalSessionFailure }); + await expect(turn.result).resolves.toEqual({ status: "failed", error: { message: "ACP agent reported a terminal limit failure.", code: "RUNTIME" } }); + expect(onTerminalSessionFailure).toHaveBeenCalledExactlyOnceWith({ category: "limit", title: "Usage limit", details: "Monthly allowance exhausted" }); + await port.close({ reason: "test complete" }); + } + }); + + it("keeps a completed turn when its response carries no typed failure", async () => { + const runtime = fakeRuntime(); + vi.mocked(runtime.startTurn).mockReturnValue({ + requestId: "turn-1", + promptStarted: Promise.resolve(), + events: { async *[Symbol.asyncIterator]() {} }, + result: Promise.resolve({ status: "completed" as const, _meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "warning", category: "limit" } } } } }), + cancel: vi.fn(), + closeStream: vi.fn(), + }); + const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), { + createRegistry: () => registry(), createStore: () => store(), createRuntime: () => runtime, + }); + const onTerminalSessionFailure = vi.fn(); + await expect(port.startTurn({ text: "Complete the task.", requestId: "turn-1", onTerminalSessionFailure }).result).resolves.toMatchObject({ status: "completed" }); + expect(onTerminalSessionFailure).not.toHaveBeenCalled(); + await port.close({ reason: "test complete" }); + }); + it("maps prompt turns to the admitted ACPX handle", async () => { const runtime = fakeRuntime(); const turn = { @@ -2222,8 +2383,20 @@ describe("Codex ACPX runtime adapter", () => { mode: "prompt", requestId: "turn-1", signal: expect.any(AbortSignal), - onElicitation, + onElicitation: expect.any(Function), + assertActive: expect.any(Function), }); + // ACPX receives a stock callback; the host callback also gets a receipt + // that resolves once its response is handed back to ACPX. + const stock = vi.mocked(runtime.startTurn).mock.calls[0]![0].onElicitation!; + vi.mocked(onElicitation).mockImplementationOnce(async (_request, context) => { + let delivered = false; + void (context as { responseDelivery: Promise }).responseDelivery.then(() => { delivered = true; }); + await Promise.resolve(); + expect(delivered).toBe(false); + return { action: "cancel" }; + }); + await expect(stock({} as never, { requestId: 1, signal: new AbortController().signal })).resolves.toEqual({ action: "cancel" }); }); it("observes prompt admission rejection when the sidecar consumes only events and the result", async () => { @@ -3683,7 +3856,7 @@ function openOptions( profile: { driverKind: "acpx_runtime", protocolVersion: 1, - acpxVersion: "0.13.1", + acpxVersion: "0.22.0", agent: "codex", agentProfileVersion: 1, agentServerPackage: "@agentclientprotocol/codex-acp", @@ -3871,3 +4044,27 @@ function registry(): AcpAgentRegistry { function store(): AcpSessionStore { return { load: vi.fn(), save: vi.fn() }; } + +/** The environment the adapter gives the verified lease when ACPX spawns the agent. */ +function spawnedAgentEnvironment( + created: AcpRuntimeOptions | undefined, + command: VerifiedAcpxCommandLease, +): NodeJS.ProcessEnv | undefined { + // Capture the launch, then fail it so no fake provider joins the session. + let captured: NodeJS.ProcessEnv | undefined; + vi.mocked(command.spawn).mockImplementationOnce((_args, spawnOptions) => { + captured = spawnOptions?.env; + throw new Error("captured launch"); + }); + // ACPX derives its own environment from this process; the adapter replaces it. + expect(() => created!.spawnAgent!({ command: "agent", args: [], options: { env: { HOST_ONLY: "host" } } })).toThrow("captured launch"); + return captured; +} + +let initializeId = 1000; +/** Deliver an initialize reply the way ACPX reports it: through `onAcpMessage`. */ +function initializeOverWire(created: AcpRuntimeOptions, result: Record): void { + const id = ++initializeId; + created.onAcpMessage!("outbound", { jsonrpc: "2.0", id, method: "initialize", params: { protocolVersion: 1 } }); + created.onAcpMessage!("inbound", { jsonrpc: "2.0", id, result: { protocolVersion: 1, ...result } }); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index e9b3b6fa9ec..3680089046c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -4,12 +4,15 @@ import { kimiReadOnlyPermissionAllowed } from "./kimi-config.js"; import { createGrokPromptUsageCollector } from "./grok-usage.js"; import { resolveGrokDisableWebSearch } from "./grok-web-search.js"; import { resolveGrokEffort, createGrokEffortAdmission } from "./grok-effort.js"; +import { createGrokSessionOpenGuard } from "./grok-session-rules.js"; import { resolveClaudeEffort, createClaudeEffortAdmission } from "./claude-effort.js"; -import { acpxProviderFailure } from "./provider-failure.js"; +import { acpxProviderFailure, acpxTypedSessionFailureClientCapabilities, acpxTypedTerminalSessionFailure, type AcpxTerminalSessionFailure } from "./provider-failure.js"; import type { ProviderFailure } from "../../contracts/provider-failure.generated.js"; import { createPiThinkingAdmission, resolvePiThinkingLevel } from "./pi-thinking.js"; import { usesPreconfiguredHome } from "../preconfigured-home.js"; import type { ChildProcess } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; import { createAcpRuntime, @@ -20,6 +23,7 @@ import { type AcpRuntime, type AcpRuntimeHandle, type AcpRuntimeOptions, + type AcpRuntimeTurnResult, type AcpSessionRecord, type AcpSessionStore, } from "acpx/runtime"; @@ -39,14 +43,16 @@ import { awaitVerifiedAcpxProviderExit, awaitVerifiedAcpxProviderOwnership, } from "./installation-integrity.js"; -import type { AcpxModelStatus } from "./model-verification.js"; +import { createAcpxPromptReceiptCollector } from "./prompt-receipts.js"; +import { createAcpxEffectiveModelGate, type AcpxEffectiveModelGate, type AcpxModelStatus } from "./model-verification.js"; import { AcpxApprovalRequiredError, decideAcpxPermission } from "./permission-policy.js"; import { ACPX_CAPABILITY_PROFILES } from "./capability-profiles.js"; import { createCursorInstructionAdmission } from "./cursor-instructions.js"; import { createAntigravityAdmission } from "./antigravity-admission.js"; import { createAcpxModeBinding } from "./provider-mode.js"; import { isAcpPromptRequest, type AcpxTurnDispatchJournal } from "./turn-dispatch-journal.js"; -import { assertCopilotPromptPolicy, createCopilotProtocolGuard } from "./copilot-policy.js"; +import { assertCopilotPromptPolicy, createCopilotPolicyEnforcement, type CopilotPolicyEnforcement } from "./copilot-policy.js"; +import { acpxElicitationHandler, createAcpxPermissionReplyConfirmations, withReturnedResponseDelivery, type AcpxHostExtensionRequestHandler, type AcpxHostPermissionHandler } from "./interaction-delivery.js"; const VERIFIED_COMMAND_SENTINEL = "paperclip-verified-acpx-command"; const DEFAULT_RUNTIME_CLOSE_TIMEOUT_MS = 2_000; @@ -96,11 +102,6 @@ interface GoalAwareAcpRuntime extends AcpRuntime { }): Promise>; } -type GoalAwareAcpRuntimeOptions = AcpRuntimeOptions & { - onAgentInitialize?: (result: unknown) => void; - onSessionNotification?: (notification: unknown) => void; -}; - interface AcpxRuntimeGoalState { capability: AcpxRuntimeGoalCapability | null; snapshot: AcpxRuntimeGoalSnapshot | null; @@ -114,7 +115,7 @@ interface AcpxRuntimeExtensionTurn { controller: AbortController; signal: AbortSignal; promptStarted: Promise; - onRequest?: AcpRuntimeOptions["onExtensionRequest"]; + onRequest?: AcpxHostExtensionRequestHandler; onNotification?: AcpRuntimeOptions["onExtensionNotification"]; onPlanProposal?: (proposal: AcpxPlanProposal) => void; } @@ -124,7 +125,7 @@ interface AcpxRuntimeExtensionBoundary { sessionIds: Set; steering: AcpxRuntimeSteeringCapability | null; /** The started turn whose first session/prompt request is not yet written. */ - promptDispatch: { turn: AcpxRuntimeExtensionTurn; journal: AcpxTurnDispatchJournal } | null; + promptDispatch: { turn: AcpxRuntimeExtensionTurn; journal: AcpxTurnDispatchJournal; failure?: { error: unknown } } | null; } /** @@ -300,10 +301,25 @@ export async function openQualifiedAcpxRuntime( ); } }; + // A reused session may never reinitialize its agent. Its goal capability is + // kept beside the agent capabilities ACPX already saves, and restored with + // them, so goal controls and Pi steering stay available after a restore. + // `null` means the latest initialize advertised no goal support. + let initializedGoalCapability: AcpxRuntimeGoalCapability | null | undefined; const sessionStore: AcpSessionStore = { async load(sessionId) { const record = await baseStore.load(sessionId); - if (record !== undefined) rememberHandshakeHandle(record); + if (record !== undefined) { + rememberHandshakeHandle(record); + const capabilities = objectRecord(record.agentCapabilities); + const stored = objectRecord(capabilities._meta)[GOAL_CAPABILITY_META_KEY]; + // A session saved by ACPX 0.13.1 kept goal support in its own field. + const legacy = stored === undefined ? await legacyGoalCapability(options.stateDirectory, record.acpxRecordId) : null; + // Persist it under the current key on the next save. + if (legacy && initializedGoalCapability === undefined) initializedGoalCapability = legacy; + const goal = stored ?? legacy ?? undefined; + applyAgentInitialize({ agentCapabilities: capabilities, ...(goal === undefined ? {} : { _meta: { goal } }) }); + } return record; }, async save(record) { @@ -311,9 +327,32 @@ export async function openQualifiedAcpxRuntime( // the store to persist it. Capture cleanup authority first so a storage // rejection cannot orphan the live session created by the handshake. rememberHandshakeHandle(record); - await baseStore.save(record); + await baseStore.save(withPersistedGoalCapability(record, initializedGoalCapability)); }, }; + const initializeRequests = new Set(); + const observeAgentInitialize = (direction: "inbound" | "outbound", message: unknown): void => { + const value = objectRecord(message); + const id = value.id; + if (typeof id !== "string" && typeof id !== "number") return; + if (direction === "outbound") { + if (value.method === "initialize" && initializeRequests.size < 16) initializeRequests.add(id); + return; + } + if (value.method !== undefined || !initializeRequests.delete(id) || value.result === undefined) return; + initializedGoalCapability = goalCapabilityFromAcpMessage({ result: value.result }); + applyAgentInitialize(value.result); + }; + const applyAgentInitialize = (result: unknown): void => { + const capability = goalCapabilityFromAcpMessage({ result }); + if (capability) goalState.capability = capability; + // Only the runner-owned Pi wrapper has an admitted steering contract. + // Never infer it from generic ACP prompt support or native-only features. + const pi = objectRecord(objectRecord(objectRecord(objectRecord(result).agentCapabilities)._meta).paperclipPi); + extensionBoundary.steering = options.profile.agent === "pi" && pi.version === 1 + ? { steering: pi.steering === true, queuedFollowUp: pi.queuedFollowUp === true } + : null; + }; const runnerOwnedMcpServerNames = new Set( options.mcpServers .filter((server) => server.runnerOwned) @@ -322,12 +361,24 @@ export async function openQualifiedAcpxRuntime( const permissionBoundary: { active: AbortController | null; hasAdmittedTurn: boolean; - handler?: AcpRuntimeOptions["onPermissionRequest"]; + handler?: AcpxHostPermissionHandler; } = { active: null, hasAdmittedTurn: false }; const extensionProfile = ACPX_CAPABILITY_PROFILES[options.profile.agent]; const extensionRequests = new Set(extensionProfile.extensionRequests); const extensionNotifications = new Set(extensionProfile.extensionNotifications); const promptUsage = options.profile.agent === "grok" ? createGrokPromptUsageCollector() : undefined; + const promptReceipts = createAcpxPromptReceiptCollector(options.profile.agent); + const permissionReplies = createAcpxPermissionReplyConfirmations(); + const modelGate = createAcpxEffectiveModelGate(options.profile.reportedModelId); + // Copilot's admitted permission policy is enforced from the observed wire: + // a violation stops the provider and refuses every later prompt. + const copilotPolicy = options.profile.agent === "copilot" + ? createCopilotPolicyEnforcement(options.profile.reportedModelId, (error) => { + permissionBoundary.active?.abort(error); + extensionBoundary.active?.controller.abort(error); + void children.terminate().catch(() => undefined); + }) + : null; const extensionBoundary: AcpxRuntimeExtensionBoundary = { active: null, sessionIds: new Set(), steering: null, promptDispatch: null, }; @@ -372,7 +423,26 @@ export async function openQualifiedAcpxRuntime( ? createAntigravityAdmission(options.systemInstructions, options.profile.reportedModelId) : null; const contextAdmission = instructionAdmission ?? antigravityAdmission; - const runtimeOptions: GoalAwareAcpRuntimeOptions = { + // The agent's complete launch environment. ACPX's own environment handling + // (credential checks, ACP terminals) keeps reading this process's environment. + const agentEnvironment = (): Record => ({ + ...definedEnvironment(options.launchEnvironment), + ...(options.profile.agent === "claude" + ? { + ...(preconfigured ? { + // Advertise the requested exact ID without rewriting user settings. + ANTHROPIC_MODEL: options.profile.reportedModelId, + ANTHROPIC_CUSTOM_MODEL_OPTION: options.profile.reportedModelId, + } : { PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1" }), + // This URL comes from the runner-owned authenticated tool bridge, + // never provider-supplied permission-request metadata. + PAPERCLIP_ACPX_TASK_TOOL_BRIDGE_URL: options.mcpServers.find( + (server): server is Extract => "url" in server && server.runnerOwned && server.name === "paperclip", + )?.url ?? "", + } + : {}), + }); + const runtimeOptions: AcpRuntimeOptions = { cwd: options.cwd, ...(instructionAdmission || modeBinding ? { protocolGuardFactory: () => { const instructions = instructionAdmission?.createGuard(); @@ -380,7 +450,11 @@ export async function openQualifiedAcpxRuntime( return (direction: "inbound" | "outbound", message: unknown) => { instructions?.(direction, message); mode?.(direction, message); }; } } : {}), ...(piThinking ? { protocolGuardFactory: () => piThinking.createGuard() } : {}), - ...(effortAdmission ? { protocolGuardFactory: () => effortAdmission.createGuard() } : {}), + ...(options.profile.agent === "grok" ? { protocolGuardFactory: () => { + const open = createGrokSessionOpenGuard({ rules: options.systemInstructions ?? "", model: options.profile.reportedModelId }); + const effort = effortAdmission?.createGuard(); + return (direction: "inbound" | "outbound", message: unknown) => { open(direction, message); effort?.(direction, message); }; + } } : effortAdmission ? { protocolGuardFactory: () => effortAdmission.createGuard() } : {}), ...(antigravityAdmission ? { protocolGuardFactory: () => antigravityAdmission.createGuard() } : {}), sessionStore, agentRegistry: createRegistry({ @@ -396,11 +470,8 @@ export async function openQualifiedAcpxRuntime( ? "approve-reads" : options.permissionMode, elicitationModes: ["form"], - ...(options.clientCapabilities === undefined ? {} : { clientCapabilities: structuredClone(options.clientCapabilities) }), + clientCapabilities: acpxTypedSessionFailureClientCapabilities(options.clientCapabilities), extensionMethods: [...new Set([...extensionRequests, ...extensionNotifications])], - ...(options.profile.agent === "copilot" ? { - protocolGuardFactory: () => createCopilotProtocolGuard(options.profile.reportedModelId), - } : {}), onExtensionRequest: async (method, params, context) => { const active = extensionBoundary.active; if (!extensionRequests.has(method) || !active?.onRequest || !ownsExtensionTurn(active, params) || context.signal.aborted) { @@ -410,10 +481,15 @@ export async function openQualifiedAcpxRuntime( // Cursor's native extensions omit sessionId. Only after admission may // that omission inherit this exact prompt's ACP wire session identity. const boundParams = params.sessionId === undefined ? { ...params, sessionId: active.sessionId } : params; - const response = await abortableExtensionResult(active.onRequest(method, boundParams, { requestId: context.requestId, signal, responseDelivery: context.responseDelivery }), signal); - signal.throwIfAborted(); - if (!ownsExtensionTurn(active, params)) throw new Error("ACPX extension request turn expired"); - return response; + const onRequest = active.onRequest; + return await withReturnedResponseDelivery( + (responseDelivery) => abortableExtensionResult(onRequest(method, boundParams, { requestId: context.requestId, signal, responseDelivery }), signal), + (response) => { + signal.throwIfAborted(); + if (!ownsExtensionTurn(active, params)) throw new Error("ACPX extension request turn expired"); + return response; + }, + ); }, onExtensionNotification: (method, params) => { const active = extensionBoundary.active; @@ -444,6 +520,7 @@ export async function openQualifiedAcpxRuntime( })), onPermissionRequest: async (request, context) => { if (preconfigured) return { outcome: "reject_once" }; + if (copilotPolicy?.violation) return { outcome: "cancel" }; const rawSessionId = objectRecord(request.raw).sessionId; if (!extensionBoundary.sessionIds.has(request.sessionId) || (rawSessionId !== undefined && rawSessionId !== request.sessionId)) { @@ -481,14 +558,22 @@ export async function openQualifiedAcpxRuntime( if (active && turn && handler && !active.signal.aborted && !turn.signal.aborted && !context.signal.aborted) { // Capture this turn's callback before awaiting. A session-lifetime // callback must never acquire the next turn's approval authority. - const decision = await handler(request, { - signal: AbortSignal.any([active.signal, turn.signal, context.signal]), - responseDelivery: context.responseDelivery, - }); + const signal = AbortSignal.any([active.signal, turn.signal, context.signal]); + const reply = permissionReplies.bind(request, signal); + let decision: Awaited>; + try { + decision = await handler(request, { signal, responseDelivery: reply.delivery }); + } catch (error) { + reply.fail(new Error("ACP permission callback failed before its decision was returned")); + throw error; + } if (permissionBoundary.active !== active || extensionBoundary.active !== turn || active.signal.aborted || turn.signal.aborted || context.signal.aborted) { + reply.select({ outcome: "cancel" }); return { outcome: "cancel" }; } - return decision ?? { outcome: "cancel" }; + const selected = decision ?? { outcome: "cancel" as const }; + reply.select(selected); + return selected; } // This runtime has no interactive approval bridge. Stop the active // turn instead of asking the model to recover from an unexplained @@ -498,46 +583,35 @@ export async function openQualifiedAcpxRuntime( } return { outcome: disposition }; }, - onAgentInitialize: (result) => { - const capability = goalCapabilityFromAcpMessage({ result }); - if (capability) goalState.capability = capability; - // Only the runner-owned Pi wrapper has an admitted steering contract. - // Never infer it from generic ACP prompt support or native-only features. - const pi = objectRecord(objectRecord(objectRecord(objectRecord(result).agentCapabilities)._meta).paperclipPi); - extensionBoundary.steering = options.profile.agent === "pi" && pi.version === 1 - ? { steering: pi.steering === true, queuedFollowUp: pi.queuedFollowUp === true } - : null; - }, - onSessionNotification: acceptGoalNotification, onAcpMessage: (direction, message) => { + observeAgentInitialize(direction, message); + copilotPolicy?.observe(direction, message); + modelGate.observe(direction, message); + // Goal updates belong to this runtime's own ACP session, not Cursor children. + if (direction === "inbound" && (extensionBoundary.sessionIds.size === 0 + || extensionBoundary.sessionIds.has(String(objectRecord(objectRecord(message).params).sessionId)))) { + acceptGoalNotification(message); + } + permissionReplies.observe(direction, message); const active = extensionBoundary.active; // ACPX calls this synchronously before it writes an outbound message to - // the provider stream, so a thrown journal error aborts the prompt write. + // the provider stream, so the record is durable before the prompt leaves. + // ACPX 0.22 swallows observer errors: keep a failed write for the turn's + // assertActive, which ACPX calls next and which aborts the prompt write. const dispatch = extensionBoundary.promptDispatch; if (direction === "outbound" && dispatch && dispatch.turn === active && isAcpPromptRequest(message)) { extensionBoundary.promptDispatch = null; - dispatch.journal.markDispatched(); + try { + dispatch.journal.markDispatched(); + } catch (error) { + dispatch.failure = { error }; + } + } + if (active && (direction === "inbound" || !active.signal.aborted)) { + promptUsage?.observe(direction, message); + promptReceipts?.observe(direction, message); } - if (active && (direction === "inbound" || !active.signal.aborted)) promptUsage?.observe(direction, message); }, - spawnEnvironment: () => ({ - ...definedEnvironment(options.launchEnvironment), - ...(options.profile.agent === "claude" - ? { - ...(preconfigured ? { - // Advertise the requested exact ID without rewriting user settings. - ANTHROPIC_MODEL: options.profile.reportedModelId, - ANTHROPIC_CUSTOM_MODEL_OPTION: options.profile.reportedModelId, - } : { PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1" }), - // This URL comes from the runner-owned authenticated tool bridge, - // never provider-supplied permission-request metadata. - PAPERCLIP_ACPX_TASK_TOOL_BRIDGE_URL: options.mcpServers.find( - (server): server is Extract => "url" in server && server.runnerOwned && server.name === "paperclip", - )?.url ?? "", - } - : {}), - }), - spawnCwd: options.cwd, spawnAgent: (input) => { // ACPX can invoke this callback after its handshake caller has already // been cancelled. Check at the last host-owned boundary so a late @@ -545,8 +619,11 @@ export async function openQualifiedAcpxRuntime( options.signal?.throwIfAborted(); options.assertWorkspaceHeld?.(); commandLaunches.count += 1; + // Each stdio connection starts with fresh Copilot admission state. + copilotPolicy?.connect(); + modelGate.connect(); return children.add( - options.command.spawn(input.args, input.options, { + options.command.spawn(input.args, { ...input.options, env: agentEnvironment() }, { credentialFenceFds, activateCredentialFenceOwner: options.activateCredentialFenceOwner!, }) as ChildProcess, @@ -571,8 +648,10 @@ export async function openQualifiedAcpxRuntime( cwd: options.cwd, sessionOptions: { // Forward the requested model unchanged; verify the provider's - // reported selection before admitting a billable prompt. - model: options.profile.reportedModelId, + // reported selection before admitting a billable prompt. Grok binds + // its model when the native session opens and rejects any later + // selection, so ACPX must not replay one after a load. + ...(options.profile.agent === "grok" ? {} : { model: options.profile.reportedModelId }), ...(preconfigured && options.profile.agent === "claude" ? { allowedTools: options.mcpServers .filter(server => server.runnerOwned && server.name.startsWith("paperclip_runner_")) @@ -749,6 +828,9 @@ export async function openQualifiedAcpxRuntime( options.profile.agent === "copilot", options.profile.agent === "copilot" ? assertCopilotPromptPolicy : undefined, promptUsage, + copilotPolicy, + modelGate, + promptReceipts, ); } catch (error) { const cleanupReason = "ACPX runtime identity validation failed"; @@ -1195,11 +1277,14 @@ function runtimePort( runtimeCloseTimeoutMs: number, goalState: AcpxRuntimeGoalState, commandLaunches: { count: number; refreshConsumedCommand?: () => Promise }, - permissionBoundary: { active: AbortController | null; hasAdmittedTurn: boolean; handler?: AcpRuntimeOptions["onPermissionRequest"] }, + permissionBoundary: { active: AbortController | null; hasAdmittedTurn: boolean; handler?: AcpxHostPermissionHandler }, extensionBoundary: AcpxRuntimeExtensionBoundary, monitorProviderDeath: boolean, assertPromptPolicy?: (text: string) => void, promptUsage?: ReturnType, + copilotPolicy?: CopilotPolicyEnforcement | null, + modelGate?: AcpxEffectiveModelGate, + promptReceipts?: ReturnType, ): AcpxRuntimePort { extensionBoundary.sessionIds = new Set([identity.backendSessionId]); let extensionControls: Promise = Promise.resolve(); @@ -1459,7 +1544,11 @@ function runtimePort( }, async getStatus() { const status = await persistedRuntimeStatus(sessionStore, handle, identity); - return promptUsage ? { ...status, terminalPromptUsage: promptUsage.snapshot() } : status; + return { + ...status, + ...(promptUsage ? { terminalPromptUsage: promptUsage.snapshot() } : {}), + ...promptReceipts?.statusFields(), + }; }, steeringCapability() { return extensionBoundary.steering === null ? null : structuredClone(extensionBoundary.steering); @@ -1559,6 +1648,7 @@ function runtimePort( } : {}), startTurn(input) { + copilotPolicy?.assertActive(); if (providerProcessLost) throw new AcpxProviderProcessLostError(); assertPromptPolicy?.(input.text); if (extensionBoundary.active) throw new Error("ACPX runtime already has an active turn"); @@ -1575,8 +1665,10 @@ function runtimePort( onPlanProposal: input.onPlanProposal, }; extensionBoundary.active = extensionTurn; - extensionBoundary.promptDispatch = dispatchJournal ? { turn: extensionTurn, journal: dispatchJournal } : null; + const promptDispatch: AcpxRuntimeExtensionBoundary["promptDispatch"] = dispatchJournal ? { turn: extensionTurn, journal: dispatchJournal } : null; + extensionBoundary.promptDispatch = promptDispatch; promptUsage?.begin(input.requestId, identity.backendSessionId); + promptReceipts?.begin(input.requestId, identity.backendSessionId); // A guardian can outlive the provider while ACP awaits a callback. // Native exit or a typed ACP agent disconnect retires this turn; losing // the controller connection must preserve deliverable input instead. @@ -1616,11 +1708,13 @@ function runtimePort( ? AbortSignal.any([input.signal, approval.signal]) : approval.signal, ...(input.onElicitation - ? { onElicitation: input.onElicitation } - : {}), - ...(input.onTerminalSessionFailure - ? { onTerminalSessionFailure: input.onTerminalSessionFailure } + ? { onElicitation: acpxElicitationHandler(input.onElicitation) } : {}), + assertActive: () => { + if (promptDispatch?.failure) throw promptDispatch.failure.error; + copilotPolicy?.assertActive(); + modelGate?.assertActive(); + }, }); } catch (error) { releaseExtensionTurn(); @@ -1633,7 +1727,9 @@ function runtimePort( void promptStarted.catch(() => undefined); extensionTurn.promptStarted = promptStarted; const result = abortableExtensionResult(guarded.result, providerDeath.signal).then( - (value) => { + (settled) => { + copilotPolicy?.assertActive(); + const value = typedTerminalTurnResult(settled, input.onTerminalSessionFailure); if (monitorProviderDeath && value.status === "failed" && (value.error?.code === "AGENT_DISCONNECTED" || value.error?.detailCode === "AGENT_DISCONNECTED")) { // ACP can terminalize before the independent exit observer runs. @@ -1649,7 +1745,7 @@ function runtimePort( } return value; }, - (error: unknown) => { approval.signal.throwIfAborted(); throw error; }, + (error: unknown) => { copilotPolicy?.assertActive(); approval.signal.throwIfAborted(); throw error; }, ).finally(() => { releaseExtensionTurn(); if (permissionBoundary.active === approval) { permissionBoundary.active = null; permissionBoundary.handler = undefined; approval.abort(); } @@ -1786,7 +1882,6 @@ async function persistedRuntimeStatus( agentSessionId: persistedAgentSessionId, lastRequestId: record.lastRequestId, requestTokenUsage: structuredClone(record.request_token_usage ?? {}), - cursorPromptUsage: structuredClone(record.cursor_prompt_usage), promptMessageIds: (record.messages ?? []).flatMap(message => typeof message === "object" && message !== null && "User" in message ? [message.User.id] : []), usageCost: structuredClone(record.cumulative_cost), ...(currentModelId === undefined && !availableModelIds?.length @@ -2370,3 +2465,56 @@ function definedEnvironment( ), ); } + +/** A typed AIR session failure fails the turn even though ACP reported a normal stop. */ +function typedTerminalTurnResult( + result: AcpRuntimeTurnResult, + onFailure?: (failure: AcpxTerminalSessionFailure) => void, +): AcpRuntimeTurnResult { + if (result.status === "failed") return result; + const failure = acpxTypedTerminalSessionFailure(result._meta); + if (!failure) return result; + try { + onFailure?.(failure); + } catch { + // Diagnostics cannot change the failed terminal outcome. + } + return { status: "failed", error: { message: `ACP agent reported a terminal ${failure.category} failure.`, code: "RUNTIME" } }; +} + +const GOAL_CAPABILITY_META_KEY = "paperclip.goalCapability"; + +/** Store the agent's goal capability under a Paperclip key ACPX persists verbatim. */ +function withPersistedGoalCapability( + record: AcpSessionRecord, + capability: AcpxRuntimeGoalCapability | null | undefined, +): AcpSessionRecord { + // Unknown since the last load: keep whatever the record already carries. + if (capability === undefined || record.agentCapabilities === undefined) return record; + const { [GOAL_CAPABILITY_META_KEY]: _previous, ...meta } = objectRecord(record.agentCapabilities._meta); + return { + ...record, + agentCapabilities: { + ...record.agentCapabilities, + _meta: capability === null ? meta : { ...meta, [GOAL_CAPABILITY_META_KEY]: structuredClone(capability) }, + }, + }; +} + +/** + * ACPX 0.13.1 with Paperclip's patch saved an agent's goal support as the + * record field `agent_goal_capability`. ACPX 0.22 drops unknown fields when it + * parses a record, so read that field from the saved file itself. + */ +async function legacyGoalCapability(stateDirectory: string, recordId: string): Promise { + try { + const raw = JSON.parse(await readFile(join(stateDirectory, "sessions", `${encodeURIComponent(recordId)}.json`), "utf8")); + const legacy = objectRecord(objectRecord(raw).agent_goal_capability); + if (Object.keys(legacy).length === 0) return null; + return goalCapabilityFromAcpMessage({ result: { _meta: { goal: { + version: legacy.version, controlMethod: legacy.control_method, actions: legacy.actions, + } } } }); + } catch { + return null; + } +} diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-policy.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-policy.ts index cfc467c5fd9..8e5c0c11404 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-policy.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-policy.ts @@ -163,3 +163,45 @@ export function createCopilotProtocolGuard(expectedModel: string): (direction: " function record(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; } + +export interface CopilotPolicyEnforcement { + readonly violation: CopilotPolicyViolationError | null; + /** Start fresh admission state for a new provider connection. */ + connect(): void; + /** Observe one wire message. ACPX ignores observer errors, so a violation is recorded and enforced here. */ + observe(direction: "inbound" | "outbound", message: unknown): void; + /** Throws the recorded violation; ACPX calls this before writing each prompt. */ + assertActive(): void; +} + +/** + * Enforce the Copilot policy with stock ACPX hooks. `onAcpMessage` sees each + * inbound message before ACPX dispatches it and each outbound message before + * its write. The first violation stops the provider, cancels later permission + * requests, and fails every later prompt admission with its typed error. + */ +export function createCopilotPolicyEnforcement( + expectedModel: string, + stopProvider: (violation: CopilotPolicyViolationError) => void, +): CopilotPolicyEnforcement { + let guard = createCopilotProtocolGuard(expectedModel); + let violation: CopilotPolicyViolationError | null = null; + return { + get violation() { return violation; }, + connect() { + if (!violation) guard = createCopilotProtocolGuard(expectedModel); + }, + observe(direction, message) { + if (violation) return; + try { + guard(direction, message); + } catch (error) { + violation = error instanceof CopilotPolicyViolationError ? error : new CopilotPolicyViolationError(); + stopProvider(violation); + } + }, + assertActive() { + if (violation) throw violation; + }, + }; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts index 0ab47a31e63..ff6db048aa6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts @@ -1,82 +1,83 @@ -import { createRequire } from "node:module"; -import { pathToFileURL } from "node:url"; import { describe, expect, it, vi } from "vitest"; -import { COPILOT_AGENT_MODE, createCopilotProtocolGuard } from "./copilot-policy.js"; +import { COPILOT_AGENT_MODE, createCopilotPolicyEnforcement } from "./copilot-policy.js"; +import { acpxInternals } from "../../../test/support/acpx-bundle-internals.mjs"; -// Exercise the installed, patched wire stream, not a mock observer. This is a +// Exercise the installed ACPX wire stream with its stock observer. This is a // pinned internal bundle export; the package-contract suite guards its version. -const bundle = new URL("./dist/live-checkpoint-BSIrfgVo.js", pathToFileURL(createRequire(import.meta.url).resolve("acpx/package.json"))); -const { k: AcpClient } = await import(bundle.href); +const { AcpClient } = await acpxInternals(["AcpClient"]); const safe = { sessionId: "s", modes: { currentModeId: COPILOT_AGENT_MODE }, configOptions: [ { id: "mode", currentValue: COPILOT_AGENT_MODE }, { id: "allow_all", currentValue: "off" }, { id: "model", currentValue: "gpt-5.6-luna" }, ] }; -function connection(guarded = true) { +function connection() { const written: unknown[] = []; let inbound!: ReadableStreamDefaultController; - const kill = vi.fn(); - const client = new AcpClient({ cwd: "/tmp", agentCommand: "fixture-never-spawned", permissionMode: "deny-all", - ...(guarded ? { protocolGuardFactory: () => createCopilotProtocolGuard("gpt-5.6-luna") } : {}), - }); - client.agent = { kill }; + const stop = vi.fn(); + const policy = createCopilotPolicyEnforcement("gpt-5.6-luna", stop); + const client = new AcpClient({ cwd: "/tmp", agentCommand: "fixture-never-spawned", permissionMode: "deny-all" }); + client.setEventHandlers({ onAcpMessage: (direction: "inbound" | "outbound", message: unknown) => policy.observe(direction, message) }); const stream = client.createTappedStream({ readable: new ReadableStream({ start(controller) { inbound = controller; } }), writable: new WritableStream({ write(message) { written.push(message); } }), }); - return { client, writer: stream.writable.getWriter(), reader: stream.readable.getReader(), inbound, written, kill }; + return { policy, stop, writer: stream.writable.getWriter(), reader: stream.readable.getReader(), inbound, written }; } -describe("patched ACPX admission guard", () => { - it.each(["session/new", "session/load", "session/resume"])("rejects unsafe %s before it reaches the SDK", async method => { +async function open(c: ReturnType, method = "session/new"): Promise { + await c.writer.write({ id: 0, method, params: method === "session/new" ? {} : { sessionId: "s" } }); + c.inbound.enqueue({ id: 0, result: safe }); + await c.reader.read(); +} + +describe("Copilot policy enforcement on the stock ACPX stream", () => { + it.each(["session/new", "session/load", "session/resume"])("stops the provider when %s restores autopilot, before the SDK sees the reply", async method => { const c = connection(); await c.writer.write({ id: 0, method, params: { sessionId: "s" } }); - const read = c.reader.read(); c.inbound.enqueue({ id: 0, result: { ...safe, modes: { currentModeId: "autopilot" } } }); - await expect(read).rejects.toMatchObject({ code: "COPILOT_POLICY_VIOLATION" }); - expect(c.kill).toHaveBeenCalledWith("SIGTERM"); + await c.reader.read(); + expect(c.stop).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ code: "COPILOT_POLICY_VIOLATION" })); + expect(() => c.policy.assertActive()).toThrow(expect.objectContaining({ code: "COPILOT_POLICY_VIOLATION" })); }); - it("rejects permission controls before any bytes reach the provider", async () => { + + it("stops the provider when a permission control is written", async () => { const c = connection(); - await c.writer.write({ id: 0, method: "session/new", params: {} }); - c.inbound.enqueue({ id: 0, result: safe }); await c.reader.read(); - await expect(c.writer.write({ id: 1, method: "session/set_config_option", params: { sessionId: "s", configId: "allow_all", value: "on" } })).rejects.toThrow(/admitted permission policy/); - expect(c.written).toHaveLength(1); expect(c.kill).toHaveBeenCalledOnce(); - c.inbound.close(); + await open(c); + await c.writer.write({ id: 1, method: "session/set_config_option", params: { sessionId: "s", configId: "allow_all", value: "on" } }); + expect(c.stop).toHaveBeenCalledOnce(); + expect(() => c.policy.assertActive()).toThrow(/admitted permission policy/); }); - it("errors and stops the provider on drift even without a notification observer", async () => { + + it("stops the provider on mid-session mode drift", async () => { const c = connection(); - await c.writer.write({ id: 0, method: "session/new", params: {} }); - c.inbound.enqueue({ id: 0, result: safe }); await c.reader.read(); + await open(c); await c.writer.write({ id: 1, method: "session/prompt", params: { sessionId: "s", prompt: [{ type: "text", text: "Perform task" }] } }); - const read = c.reader.read(); c.inbound.enqueue({ method: "session/update", params: { sessionId: "s", update: { sessionUpdate: "current_mode_update", currentModeId: "autopilot" } } }); - await expect(read).rejects.toMatchObject({ code: "COPILOT_POLICY_VIOLATION" }); - expect(c.kill).toHaveBeenCalledWith("SIGTERM"); + await c.reader.read(); + expect(c.stop).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ code: "COPILOT_POLICY_VIOLATION" })); }); - it("preserves unguarded provider wire behavior", async () => { - const c = connection(false); - const message = { method: "session/update", params: { update: { sessionUpdate: "current_mode_update", currentModeId: "other-provider-mode" } } }; - c.inbound.enqueue(message); - expect((await c.reader.read()).value).toEqual(message); - expect(c.kill).not.toHaveBeenCalled(); - c.inbound.close(); - }); - it("blocks detached command before the permission request can authorize a side effect", async () => { + + it("records detached work before its permission request reaches the SDK", async () => { const c = connection(); - await c.writer.write({ id: 0, method: "session/new", params: {} }); - c.inbound.enqueue({ id: 0, result: safe }); await c.reader.read(); + await open(c); await c.writer.write({ id: 1, method: "session/prompt", params: { sessionId: "s", prompt: [{ type: "text", text: "Run task" }] } }); - const read = c.reader.read(); - const pendingSdkRequest = c.client.runConnectionRequest(() => new Promise(() => {})); - const pendingRejection = expect(pendingSdkRequest).rejects.toMatchObject({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED" }); c.inbound.enqueue({ method: "session/update", params: { sessionId: "s", update: { sessionUpdate: "tool_call", toolCallId: "native-detached", kind: "execute", status: "pending", rawInput: { command: "fixture", mode: "async", detach: true }, } } }); - c.inbound.enqueue({ id: 0, method: "session/request_permission", params: { sessionId: "s", toolCall: { toolCallId: "native-detached" } } }); - await expect(read).rejects.toMatchObject({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED", message: expect.stringContaining("Run the command attached") }); - await pendingRejection; - expect(c.written).toHaveLength(2); // Only session/new and session/prompt; no permission response. - expect(c.kill).toHaveBeenCalledWith("SIGTERM"); + await c.reader.read(); + expect(c.stop).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED" })); + expect(() => c.policy.assertActive()).toThrow(/Run the command attached/); + }); + + it("keeps a safe session admitted and starts each connection fresh", async () => { + const c = connection(); + await open(c); + await c.writer.write({ id: 1, method: "session/prompt", params: { sessionId: "s", prompt: [{ type: "text", text: "Perform task" }] } }); + expect(c.stop).not.toHaveBeenCalled(); + c.policy.assertActive(); + c.policy.connect(); + // A new connection has no admitted session until it opens one. + c.policy.observe("outbound", { id: 2, method: "session/prompt", params: { sessionId: "s", prompt: [{ type: "text", text: "Perform task" }] } }); + expect(c.stop).toHaveBeenCalledOnce(); }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-instructions.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-instructions.test.ts index 9fed89fca70..c2cc621503c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-instructions.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-instructions.test.ts @@ -3,15 +3,15 @@ import { createRequire } from "node:module"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { pathToFileURL } from "node:url"; import { expect, it } from "vitest"; import { admitCursorInstructions, createCursorInstructionAdmission, cursorInstructionBinding } from "./cursor-instructions.js"; import { prepareAcpxRuntimeSandbox } from "./runtime-sandbox.js"; import { createAcpxRecoveryBinding } from "./recovery-identity.js"; import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; +import { acpxInternals } from "../../../test/support/acpx-bundle-internals.mjs"; const root = dirname(createRequire(import.meta.url).resolve("acpx/package.json")); -const { k: AcpClient } = await import(pathToFileURL(join(root, "dist/live-checkpoint-BSIrfgVo.js")).href); +const { AcpClient } = await acpxInternals(["AcpClient"], root); const content = "Paperclip governance\nCustom entry: rรฉsumรฉ\nAGENT_HOME=/registered/agent"; const binding = cursorInstructionBinding(content); const ack = { schema: "paperclip.cursor.instructions.v1", digest: binding.digest, byteLength: binding.byteLength }; @@ -155,7 +155,7 @@ it.each(["valid", "missing", "wrong", "unsupported"])("fresh runtime manager adm expect(children.length).toBe(before); // The exact original cold-reopen gap. expect(second.admission.assertReady).toThrow(/did not acknowledge/); const admit = admitCursorInstructions(second.admission, { providerSpawned: false, - load: () => second!.runtime.setConfigOption({ handle: secondHandle!, key: "model", value: "fixture" }), + load: async () => { await second!.runtime.setConfigOption({ handle: secondHandle!, key: "model", value: "fixture" }); }, refreshCommand: async () => { renewed++; }, }); if (mode === "valid") { @@ -165,7 +165,8 @@ it.each(["valid", "missing", "wrong", "unsupported"])("fresh runtime manager adm const drain = (async () => { for await (const _event of turn.events) { /* drain */ } })(); expect((await turn.result).status).toBe("completed"); await drain; const methods = writes.filter(row => row.phase === "second").map(row => row.method); - expect(methods).toEqual(["initialize", "session/load", "session/set_config_option", "initialize", "session/load", "session/prompt"]); + // The turn's own load replays the saved model before the prompt. + expect(methods).toEqual(["initialize", "session/load", "session/set_config_option", "initialize", "session/load", "session/set_config_option", "session/prompt"]); } else { await expect(admit).rejects.toThrow(mode === "unsupported" ? /Unsupported exact model/ : /Cursor instruction admission failed/); expect(renewed).toBe(0); expect(writes.some(row => row.phase === "second" && row.method === "session/prompt")).toBe(false); diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-mode.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-mode.test.ts index 1ed85b6b545..ee338679a72 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-mode.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-mode.test.ts @@ -183,7 +183,9 @@ require('node:readline').createInterface({input:process.stdin}).on('line',line=> const turn = second.runtime.startTurn({ handle: secondHandle, text: "fixture", mode: "prompt", requestId: "cold-mode-turn", timeoutMs: 2000 }); const drain = (async () => { for await (const _event of turn.events) { /* drain */ } })(); expect((await turn.result).status).toBe("completed"); await drain; - expect(methods.filter(m => m === "session/set_config_option")).toHaveLength(2); + // The cold model control's load replays the saved model before setting + // it, then mode; the turn's reconnect replays the saved model and mode. + expect(methods.filter(m => m === "session/set_config_option")).toHaveLength(5); expect(methods.filter(m => m === "session/load")).toHaveLength(3); expect(methods.filter(m => m === "session/prompt")).toHaveLength(1); } diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-permission-identity.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-permission-identity.test.ts index 4c95ad21263..f88268491a3 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-permission-identity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-permission-identity.test.ts @@ -9,6 +9,7 @@ import { normalizeAcpxPermission } from "./acp-permission-adapter.js"; import { cursorToolIdentity } from "./cursor-plan-tool-identity.js"; import { createCursorToolEvidence } from "./cursor-tool-evidence.js"; import { validateAcpxRichEvent } from "./profile-extensions.js"; +import { createAcpxPermissionReplyConfirmations } from "./interaction-delivery.js"; const rawIds = ["native\u0000tool", "native\u007ftool", "native\u0085tool", "tool/1", "x".repeat(161), "safe-tool-1"]; // Synthetic protocol fixture, not a claim about the unretained paid wire bytes. @@ -41,12 +42,15 @@ it.each(["tool-first", "permission-first"])("real ACPX streams Cursor tool origi const replies: unknown[] = []; const fields: Array> = []; let promptSettled = false; + // Mirrors the runtime adapter: the exact outbound reply proves delivery. + const confirmations = createAcpxPermissionReplyConfirmations(); const evidence = createCursorToolEvidence({ sessionId: "native", turnId: "turn", workingDirectory: cwd, active: () => true, emit: event => { validateAcpxRichEvent(event); fields.push(Object.fromEntries((event.payload.details as Array<{ name: string; value: string }>).map(field => [field.name, field.value]))); }, }); const runtime = createAcpRuntime({ cwd, agentRegistry: createAgentRegistry({ overrides: { cursor: "fixture" } }), sessionStore: createRuntimeStore({ stateDir: join(cwd, "state") }), permissionMode: "approve-reads", spawnAgent: () => { const child = spawn(process.execPath, ["-e", peer], { cwd, env: { ORDER: order }, stdio: ["pipe", "pipe", "pipe"] }); children.push(child); return child; }, onAcpMessage: (direction, value) => { + confirmations.observe(direction, value); const message = value as { id?: number; result?: { outcome?: unknown } }; if (direction === "outbound" && message.result?.outcome) replies.push(structuredClone(message)); }, @@ -55,12 +59,14 @@ it.each(["tool-first", "permission-first"])("real ACPX streams Cursor tool origi const normalized = normalizeAcpxPermission(request, { provider: "cursor" }); const delivered = evidence.permission(request, `request-${pending.length}`, normalized.choices.map(choice => choice.key)); const gate = Promise.withResolvers(); - pending.push({ request, before, release: gate.resolve, delivery: context.responseDelivery }); + const reply = confirmations.bind(request, context.signal); + pending.push({ request, before, release: gate.resolve, delivery: reply.delivery }); await gate.promise; const decision = normalized.resolve({ action: "decline" }); // Delivery is observed outside this callback; waiting here would deadlock // the ACP response writer that must receive the callback result first. - void context.responseDelivery?.then(() => delivered?.(decision.outcome)); + void reply.delivery.then(() => delivered?.(decision.outcome)); + reply.select(decision); return decision; }, }); diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-sdk-interactions.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-sdk-interactions.test.ts index 8bf956f9af7..87e83b52857 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-sdk-interactions.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-sdk-interactions.test.ts @@ -4,12 +4,12 @@ import { createRequire } from "node:module"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { pathToFileURL } from "node:url"; import { expect, it } from "vitest"; import { createCursorProfileExtensionAdapter } from "./cursor-extensions.js"; +import { acpxInternals } from "../../../test/support/acpx-bundle-internals.mjs"; const require = createRequire(import.meta.url); const acpxRoot = dirname(require.resolve("acpx/package.json")); -const { k: AcpClient } = await import(pathToFileURL(join(acpxRoot, "dist/live-checkpoint-BSIrfgVo.js")).href); +const { AcpClient } = await acpxInternals(["AcpClient"], acpxRoot); // The fixture uses native Cursor's sessionless extension shape and request ID 0. // The real installed ACP SDK handles the pipe; the production adapter normalizes // answers. This is credential-free protocol coverage, not native availability. @@ -30,7 +30,7 @@ require('node:readline').createInterface({input:process.stdin}).on('line',line=> send({id:prompt,result:{stopReason:'end_turn'}});prompt=undefined; } });`; -type Context = { requestId: string | number; signal: AbortSignal; responseDelivery?: Promise }; +type Context = { requestId: string | number; signal: AbortSignal }; async function withClient(onRequest: (method: string, params: Record, context: Context) => Promise, run: (client: InstanceType, received: unknown[]) => Promise) { const cwd = await mkdtemp(join(tmpdir(), "cursor-sdk-")); const received: unknown[] = []; const client = new AcpClient({ agentCommand: "owned-fixture", cwd, permissionMode: "approve-all", fs: false, terminal: false, authPolicy: "skip", suppressSdkConsoleErrors: true, @@ -42,10 +42,9 @@ async function withClient(onRequest: (method: string, params: Record { - const deliveries: Promise[] = []; let turn = 0; + let turn = 0; await withClient(async (method, params, context) => { expect(context.requestId).toBe(0); expect(params).not.toHaveProperty("sessionId"); - expect(context.responseDelivery).toBeInstanceOf(Promise); deliveries.push(context.responseDelivery!); const adapter = createCursorProfileExtensionAdapter({ workspacePath: tmpdir(), sessionId: "cursor-session", turnId: `turn-${++turn}` }); const request = await adapter.request(method, { ...params, sessionId: "cursor-session" }); if (!("input" in request)) throw new Error("Expected blocking Cursor interaction"); @@ -60,7 +59,7 @@ it("roundtrips Cursor multi-select and revision-bound plan decisions through the return request.input.resolve({ action: "submit", response: { schema: "paperclip.question_response.v1", answers } }); }, async (client, received) => { expect((await client.prompt("cursor-session", "question")).stopReason).toBe("end_turn"); - expect((await client.prompt("cursor-session", "plan")).stopReason).toBe("end_turn"); await Promise.all(deliveries); + expect((await client.prompt("cursor-session", "plan")).stopReason).toBe("end_turn"); expect(received).toEqual([ { jsonrpc: "2.0", id: 0, result: { outcome: { outcome: "answered", answers: [{ questionId: "native-single", selectedOptionIds: ["c"] }, { questionId: "native-multi", selectedOptionIds: ["cedar", "maple"] }] } } }, { jsonrpc: "2.0", id: 0, result: { outcome: { outcome: "accepted" } } }, diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-usage-receipt.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-usage-receipt.ts index afa9ab0677c..279ca20afd3 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-usage-receipt.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-usage-receipt.ts @@ -4,26 +4,20 @@ import type { QualifiedAcpxAgent } from "./qualified-profiles.js"; const record = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -type CursorPromptUsage = { - request_id: string; prompt_message_id: string; - receipt: { - schema: "paperclip.cursor.native-usage.v1"; source: "native_turn_ended"; promptId: string; - completeness: "partial"; reasons: string[]; truncated: boolean; - limits: { maxObservations: 64; maxInvocations: 64; maxBytes: 16384 }; - observations: Array<{ invocationId: string; role: "parent" | "child"; nativeRun: number; sequence: number; counters: Record }>; - }; +type CursorNativeUsageReceipt = { + schema: "paperclip.cursor.native-usage.v1"; source: "native_turn_ended"; promptId: string; + completeness: "partial"; reasons: string[]; truncated: boolean; + limits: { maxObservations: 64; maxInvocations: 64; maxBytes: 16384 }; + observations: Array<{ invocationId: string; role: "parent" | "child"; nativeRun: number; sequence: number; counters: Record }>; }; /** Closed diagnostic receipt. Counter semantics and billing are always unverified. */ -export function parseCursorPromptUsage(raw: unknown): CursorPromptUsage | null { +export function parseCursorNativeUsageReceipt(raw: unknown): CursorNativeUsageReceipt | null { try { const text = JSON.stringify(raw); if (!text || Buffer.byteLength(text, "utf8") > 16384) return null; - const v = JSON.parse(text) as CursorPromptUsage; + const r = JSON.parse(text) as CursorNativeUsageReceipt; const object = (x: unknown): x is object => x !== null && typeof x === "object" && !Array.isArray(x); const exact = (x: object, keys: readonly string[]) => Object.keys(x).length === keys.length && keys.every(k => Object.hasOwn(x, k)); - const id = (x: unknown) => typeof x === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,239}$/.test(x); - if (!object(v) || !exact(v, ["request_id", "prompt_message_id", "receipt"]) || !id(v.request_id) || !id(v.prompt_message_id)) return null; - const r = v.receipt; if (!object(r) || !exact(r, ["schema", "source", "promptId", "completeness", "reasons", "observations", "limits", "truncated"])) return null; if (r.schema !== "paperclip.cursor.native-usage.v1" || r.source !== "native_turn_ended" || r.completeness !== "partial" || typeof r.promptId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(r.promptId) || typeof r.truncated !== "boolean") return null; const reasons = ["native_counter_semantics_unverified", "native_counters_missing", "invalid_native_counter", "multiple_terminal_observations", "native_terminal_not_observed", "observation_limit_reached", "child_run_attribution_unverified"]; @@ -38,29 +32,33 @@ export function parseCursorPromptUsage(raw: unknown): CursorPromptUsage | null { if (previous && (previous.role !== o.role || previous.nativeRun !== o.nativeRun || o.sequence <= previous.sequence)) return null; invocations.set(o.invocationId, { role: o.role, nativeRun: o.nativeRun, sequence: o.sequence }); } - return v; + return r; } catch { return null; } } -/** Partial native observations never enter usage_update, aggregate tokens, or USD. */ +/** + * Partial native observations never enter usage_update, aggregate tokens, or USD. + * The receipt is the one Cursor attached to this turn's own prompt reply + * (`cursorPromptReceipt`, bound by request id on the wire), so an older receipt + * can never be reported as fresh evidence. + */ export function persistedCursorUsageNotice(before: unknown, after: unknown, requestId: string, agent: QualifiedAcpxAgent | null, itemId: string): CanonicalProviderEvent | null { if (agent !== "cursor") return null; const current = record(after), prior = record(before); if (current.lastRequestId !== requestId || prior.lastRequestId === requestId) return null; - const receipt = parseCursorPromptUsage(current.cursorPromptUsage); - if (!receipt || receipt.request_id !== requestId) return null; - const previous = parseCursorPromptUsage(prior.cursorPromptUsage); - if (previous && (previous.request_id === receipt.request_id || previous.prompt_message_id === receipt.prompt_message_id || previous.receipt.promptId === receipt.receipt.promptId)) return null; - // A message must have been created by this turn; replacing metadata on an old message is not fresh evidence. - if (!Array.isArray(current.promptMessageIds) || current.promptMessageIds.filter(id => id === receipt.prompt_message_id).length !== 1 - || !Array.isArray(prior.promptMessageIds) || prior.promptMessageIds.includes(receipt.prompt_message_id)) return null; + const bound = record(current.cursorPromptReceipt); + if (bound.request_id !== requestId) return null; + const receipt = parseCursorNativeUsageReceipt(bound.receipt); + if (!receipt) return null; + const previous = record(prior.cursorPromptReceipt); + if (previous.request_id === requestId || parseCursorNativeUsageReceipt(previous.receipt)?.promptId === receipt.promptId) return null; const details = [ { name: "Provenance", value: "Cursor native turnEnded observations; partial, unsummed, unverified counter semantics" }, - { name: "Partial reasons", value: receipt.receipt.reasons.join(", ") }, - { name: "Collector truncated", value: String(receipt.receipt.truncated) }, - ...Array.from({ length: Math.ceil(receipt.receipt.observations.length / 8) }, (_, group) => ({ - name: `Native observations ${group * 8 + 1}-${Math.min((group + 1) * 8, receipt.receipt.observations.length)}`, - value: JSON.stringify(receipt.receipt.observations.slice(group * 8, (group + 1) * 8)), + { name: "Partial reasons", value: receipt.reasons.join(", ") }, + { name: "Collector truncated", value: String(receipt.truncated) }, + ...Array.from({ length: Math.ceil(receipt.observations.length / 8) }, (_, group) => ({ + name: `Native observations ${group * 8 + 1}-${Math.min((group + 1) * 8, receipt.observations.length)}`, + value: JSON.stringify(receipt.observations.slice(group * 8, (group + 1) * 8)), })), ]; // Eight closed observations fit within each 4,000-byte detail, without dropping any counters. diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts index 43095950840..e9be5e1be44 100644 --- a/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts @@ -37,7 +37,7 @@ describe("ACPX driver profile", () => { expect(acpxDriverDescriptor("claude")).toMatchObject({ kind: "acpx_runtime", displayName: "Claude via ACPX", - version: "0.13.1", + version: "0.22.0", protocolVersion: "acp/v1", runtimeContextCapabilities: { instructions: "native", diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 55b91c6f916..858e3566941 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -1,5 +1,5 @@ // Generated from acpx-profiles.json and cursor-distributions.json. Do not edit. -export const QUALIFIED_ACPX_VERSION = "0.13.1" as const; +export const QUALIFIED_ACPX_VERSION = "0.22.0" as const; export const ACPX_DRIVER_KIND = "acpx_runtime" as const; export const ACPX_DRIVER_PROTOCOL_VERSION = 1 as const; @@ -7,7 +7,7 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "antigravity": { "driverKind": "acpx_runtime", "protocolVersion": 1, - "acpxVersion": "0.13.1", + "acpxVersion": "0.22.0", "agent": "antigravity", "agentProfileVersion": 1, "agentServerPackage": "native:antigravity-acp", @@ -21,7 +21,7 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "grok": { "driverKind": "acpx_runtime", "protocolVersion": 1, - "acpxVersion": "0.13.1", + "acpxVersion": "0.22.0", "agent": "grok", "agentProfileVersion": 20, "agentServerPackage": "builtin:grok-acp", @@ -34,7 +34,7 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "pi": { "driverKind": "acpx_runtime", "protocolVersion": 1, - "acpxVersion": "0.13.1", + "acpxVersion": "0.22.0", "agent": "pi", "agentProfileVersion": 37, "agentServerPackage": "pi-acp", @@ -47,7 +47,7 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "cursor": { "driverKind": "acpx_runtime", "protocolVersion": 1, - "acpxVersion": "0.13.1", + "acpxVersion": "0.22.0", "agent": "cursor", "agentProfileVersion": 17, "agentServerPackage": "cursor-agent", @@ -60,7 +60,7 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "copilot": { "driverKind": "acpx_runtime", "protocolVersion": 1, - "acpxVersion": "0.13.1", + "acpxVersion": "0.22.0", "agent": "copilot", "agentProfileVersion": 92, "agentServerPackage": "@github/copilot", @@ -73,7 +73,7 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "claude": { "driverKind": "acpx_runtime", "protocolVersion": 1, - "acpxVersion": "0.13.1", + "acpxVersion": "0.22.0", "agent": "claude", "agentProfileVersion": 1, "agentServerPackage": "@agentclientprotocol/claude-agent-acp", @@ -86,7 +86,7 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "codex": { "driverKind": "acpx_runtime", "protocolVersion": 1, - "acpxVersion": "0.13.1", + "acpxVersion": "0.22.0", "agent": "codex", "agentProfileVersion": 1, "agentServerPackage": "@agentclientprotocol/codex-acp", @@ -99,7 +99,7 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "kimi": { "driverKind": "acpx_runtime", "protocolVersion": 1, - "acpxVersion": "0.13.1", + "acpxVersion": "0.22.0", "agent": "kimi", "agentProfileVersion": 17, "agentServerPackage": "@moonshot-ai/kimi-code", diff --git a/packages/paperclip-runner/src/drivers/acpx/grok-native-session-options.test.ts b/packages/paperclip-runner/src/drivers/acpx/grok-native-session-options.test.ts index 317feaf02cd..436cf465304 100644 --- a/packages/paperclip-runner/src/drivers/acpx/grok-native-session-options.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/grok-native-session-options.test.ts @@ -85,8 +85,9 @@ it.each(["low", "high"] as const)("transports and restores native Grok %s effort protocolGuardFactory: () => admission.createGuard(), spawnAgent: () => spawn(process.execPath, [fixture], { cwd: root, env: {}, stdio: "pipe" }), }; - const input = { sessionKey: "grok-native-options", agent: "grok", mode: "persistent" as const, - sessionOptions: { model } }; + // Like the runtime adapter, bind Grok's model only through session-open + // metadata: ACPX would replay a registered model with a rejected setter. + const input = { sessionKey: "grok-native-options", agent: "grok", mode: "persistent" as const }; let runtime = createAcpRuntime(options); let handle: AcpRuntimeHandle | undefined; try { diff --git a/packages/paperclip-runner/src/drivers/acpx/grok-session-rules.ts b/packages/paperclip-runner/src/drivers/acpx/grok-session-rules.ts new file mode 100644 index 00000000000..2a273cbca69 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/grok-session-rules.ts @@ -0,0 +1,23 @@ +/** + * Grok Build binds its model only when a native session opens: it has no + * standard model setter. It also reads task instructions from `session/new` + * `_meta.rules`, not from ACPX's Claude-shaped session metadata. Write both on + * the wire for every session open, whether or not an effort is selected; the + * effort guard then adds the effort and checks the same model. + */ +export function createGrokSessionOpenGuard(options: { rules: string; model: string }): (direction: "inbound" | "outbound", value: unknown) => void { + return (direction, value) => { + const message = record(value); + if (direction !== "outbound") return; + if (message.method === "session/new") { + message.params = { ...record(message.params), _meta: { rules: options.rules, modelId: options.model } }; + } else if (message.method === "session/load" || message.method === "session/resume") { + const params = record(message.params); + message.params = { ...params, _meta: { ...record(params._meta), modelId: options.model } }; + } + }; +} + +function record(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/interaction-delivery.test.ts b/packages/paperclip-runner/src/drivers/acpx/interaction-delivery.test.ts new file mode 100644 index 00000000000..efb81812baf --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/interaction-delivery.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from "vitest"; +import { acpxElicitationHandler, createAcpxPermissionReplyConfirmations, withReturnedResponseDelivery } from "./interaction-delivery.js"; + +const raw = (toolCallId: string) => ({ sessionId: "s", toolCall: { toolCallId } }); +const options = [{ optionId: "allow", kind: "allow_once" }, { optionId: "deny", kind: "reject_once" }]; +const request = (id: number, toolCallId = "tool-1") => ({ jsonrpc: "2.0", id, method: "session/request_permission", params: { ...raw(toolCallId), options } }); +const selected = { outcome: { outcome: "selected", optionId: "allow" } }; + +async function settled(promise: Promise): Promise<"resolved" | Error | "pending"> { + return await Promise.race([ + promise.then(() => "resolved" as const, (error: Error) => error), + new Promise<"pending">(resolve => setTimeout(() => resolve("pending"), 5)), + ]); +} + +describe("ACP permission reply confirmation", () => { + it("resolves only when ACPX writes the selected decision as the exact request's reply", async () => { + const replies = createAcpxPermissionReplyConfirmations(); + replies.observe("inbound", request(4)); + const binding = replies.bind({ sessionId: "s", raw: raw("tool-1") }, new AbortController().signal); + binding.select({ outcome: "allow_once" }); + replies.observe("outbound", { jsonrpc: "2.0", id: 9, result: selected }); + expect(await settled(binding.delivery)).toBe("pending"); + replies.observe("outbound", { jsonrpc: "2.0", id: 4, result: selected }); + expect(await settled(binding.delivery)).toBe("resolved"); + }); + + it.each([ + ["an error reply", { jsonrpc: "2.0", id: 4, error: { code: -32603, message: "failed" } }, /error instead/], + ["a cancellation in place of the decision", { jsonrpc: "2.0", id: 4, result: { outcome: { outcome: "cancelled" } } }, /changed the selected/], + ["another offered option", { jsonrpc: "2.0", id: 4, result: { outcome: { outcome: "selected", optionId: "deny" } } }, /changed the selected/], + ["an option that was not offered", { jsonrpc: "2.0", id: 4, result: { outcome: { outcome: "selected", optionId: "elsewhere" } } }, /changed the selected/], + ["an empty result", { jsonrpc: "2.0", id: 4, result: {} }, /changed the selected/], + ])("rejects %s", async (_label, reply, error) => { + const replies = createAcpxPermissionReplyConfirmations(); + replies.observe("inbound", request(4)); + const binding = replies.bind({ sessionId: "s", raw: raw("tool-1") }, new AbortController().signal); + binding.select({ outcome: "allow_once" }); + replies.observe("outbound", reply); + await expect(binding.delivery).rejects.toThrow(error); + }); + + it("rejects an allow written for a deny decision, and accepts the exact option id", async () => { + const replies = createAcpxPermissionReplyConfirmations(); + replies.observe("inbound", request(4)); + const deny = replies.bind({ sessionId: "s", raw: raw("tool-1") }, new AbortController().signal); + deny.select({ outcome: "reject_once" }); + replies.observe("outbound", { jsonrpc: "2.0", id: 4, result: selected }); + await expect(deny.delivery).rejects.toThrow(/changed the selected/); + replies.observe("inbound", request(5, "tool-2")); + const exact = replies.bind({ sessionId: "s", raw: raw("tool-2") }, new AbortController().signal); + exact.select({ outcome: "selected", optionId: "deny" }); + replies.observe("outbound", { jsonrpc: "2.0", id: 5, result: { outcome: { outcome: "selected", optionId: "deny" } } }); + await expect(exact.delivery).resolves.toBeUndefined(); + }); + + it("accepts a cancelled reply when the host cancelled", async () => { + const replies = createAcpxPermissionReplyConfirmations(); + replies.observe("inbound", request(4)); + const binding = replies.bind({ sessionId: "s", raw: raw("tool-1") }, new AbortController().signal); + binding.select({ outcome: "cancel" }); + replies.observe("outbound", { jsonrpc: "2.0", id: 4, result: { outcome: { outcome: "cancelled" } } }); + await expect(binding.delivery).resolves.toBeUndefined(); + }); + + it("fails closed for an unknown or ambiguous provider request", async () => { + const replies = createAcpxPermissionReplyConfirmations(); + const unknown = replies.bind({ sessionId: "s", raw: raw("tool-1") }, new AbortController().signal); + await expect(unknown.delivery).rejects.toThrow(/no unique provider request id/); + replies.observe("inbound", request(1, "tool-2")); + replies.observe("inbound", request(2, "tool-2")); + const ambiguous = replies.bind({ sessionId: "s", raw: raw("tool-2") }, new AbortController().signal); + await expect(ambiguous.delivery).rejects.toThrow(/no unique provider request id/); + }); + + it("rejects when the turn aborts, the callback fails, or no reply is written", async () => { + vi.useFakeTimers(); + try { + const replies = createAcpxPermissionReplyConfirmations({ replyTimeoutMs: 50 }); + replies.observe("inbound", request(1, "a")); + replies.observe("inbound", request(2, "b")); + replies.observe("inbound", request(3, "c")); + const controller = new AbortController(); + const aborted = replies.bind({ sessionId: "s", raw: raw("a") }, controller.signal); + controller.abort(); + await expect(aborted.delivery).rejects.toThrow(/cancelled or disconnected/); + const failed = replies.bind({ sessionId: "s", raw: raw("b") }, new AbortController().signal); + failed.fail(new Error("callback failed")); + await expect(failed.delivery).rejects.toThrow("callback failed"); + const unwritten = replies.bind({ sessionId: "s", raw: raw("c") }, new AbortController().signal); + unwritten.select({ outcome: "allow_once" }); + const rejection = expect(unwritten.delivery).rejects.toThrow(/not written/); + await vi.advanceTimersByTimeAsync(50); + await rejection; + } finally { + vi.useRealTimers(); + } + }); + + it("forgets requests ACPX answers from its own policy", async () => { + const replies = createAcpxPermissionReplyConfirmations(); + replies.observe("inbound", request(1)); + replies.observe("outbound", { jsonrpc: "2.0", id: 1, result: selected }); + replies.observe("inbound", request(2)); + const binding = replies.bind({ sessionId: "s", raw: raw("tool-1") }, new AbortController().signal); + binding.select({ outcome: "allow_once" }); + replies.observe("outbound", { jsonrpc: "2.0", id: 2, result: selected }); + await expect(binding.delivery).resolves.toBeUndefined(); + }); +}); + +describe("returned response delivery", () => { + it("resolves after the callback returns and rejects when it fails", async () => { + let receipt!: Promise; + let delivered = false; + const value = withReturnedResponseDelivery(async (responseDelivery) => { + receipt = responseDelivery; + void responseDelivery.then(() => { delivered = true; }); + await Promise.resolve(); + expect(delivered).toBe(false); + return "answer"; + }); + await expect(value).resolves.toBe("answer"); + await expect(receipt).resolves.toBeUndefined(); + + let failedReceipt!: Promise; + await expect(withReturnedResponseDelivery(async (responseDelivery) => { + failedReceipt = responseDelivery; + throw new Error("declined"); + })).rejects.toThrow("declined"); + await expect(failedReceipt).rejects.toThrow(/before its response was handed to ACPX/); + }); + + it("rejects delivery when the accepted response is refused", async () => { + let receipt!: Promise; + await expect(withReturnedResponseDelivery(async (responseDelivery) => { receipt = responseDelivery; return 1; }, () => { + throw new Error("turn expired"); + })).rejects.toThrow("turn expired"); + await expect(receipt).rejects.toThrow(); + }); + + it("adapts a Runner elicitation callback to the stock ACPX callback", async () => { + const handler = vi.fn(async (_request: unknown, context: { responseDelivery: Promise }) => { + expect(context.responseDelivery).toBeInstanceOf(Promise); + return { action: "cancel" as const }; + }); + const stock = acpxElicitationHandler(handler as never); + await expect(stock({} as never, { requestId: 1, signal: new AbortController().signal })).resolves.toEqual({ action: "cancel" }); + expect(handler).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/interaction-delivery.ts b/packages/paperclip-runner/src/drivers/acpx/interaction-delivery.ts new file mode 100644 index 00000000000..e724821e6e1 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/interaction-delivery.ts @@ -0,0 +1,217 @@ +import type { + AcpElicitationContext, + AcpElicitationHandler, + AcpPermissionDecision, + AcpPermissionRequest, +} from "acpx/runtime"; + +/** + * Runner-owned interaction callbacks. ACPX invokes the stock callbacks; the + * adapter adds `responseDelivery`, which resolves only once the selected + * response has been handed to ACPX as the provider reply. + * + * - Permission replies are visible to `onAcpMessage`, so delivery waits for + * the exact outbound reply (see createAcpxPermissionReplyConfirmations). + * - ACPX hides elicitation and extension replies from observers, so their + * delivery resolves when the callback returns its response to ACPX. + */ +export type AcpxHostPermissionHandler = ( + request: AcpPermissionRequest, + context: { signal: AbortSignal; responseDelivery: Promise }, +) => Promise; +export type AcpxHostElicitationHandler = ( + request: Parameters[0], + context: AcpElicitationContext & { responseDelivery: Promise }, +) => ReturnType; +export type AcpxHostExtensionRequestHandler = ( + method: string, + params: Record, + context: { requestId: string | number; signal: AbortSignal; responseDelivery: Promise }, +) => Promise>; + +/** Run a callback whose reply ACPX writes after it returns. */ +export async function withReturnedResponseDelivery( + invoke: (responseDelivery: Promise) => Promise, + accept: (value: T) => T = value => value, +): Promise { + const delivered = deferred(); + // A declined interaction may never await its receipt. + delivered.promise.catch(() => undefined); + try { + const value = accept(await invoke(delivered.promise)); + delivered.resolve(); + return value; + } catch (error) { + delivered.reject(new Error("ACP interaction ended before its response was handed to ACPX")); + throw error; + } +} + +/** Adapt a Runner elicitation callback to the stock ACPX callback. */ +export function acpxElicitationHandler(handler: AcpxHostElicitationHandler): AcpElicitationHandler { + return (request, context) => withReturnedResponseDelivery(responseDelivery => Promise.resolve(handler(request, { ...context, responseDelivery }))); +} + +/** + * Confirms that ACPX wrote the host's permission decision as the JSON-RPC + * reply to the exact provider request. + * + * `onAcpMessage` sees each inbound `session/request_permission` before ACPX + * dispatches it, and each outbound reply just before the pipe write. Pairing + * the two by session and tool call recovers the request id, which the stock + * permission callback does not expose. Delivery resolves only when that reply + * selects exactly the host decision: the chosen option for an option id, the + * single offered option of a decision kind, or a cancellation for a cancel. + * An error reply, any other result, an ambiguous request, a turn abort, or a + * missing reply rejects it. + */ +type JsonRpcId = string | number; + +export interface AcpxPermissionReplyBinding { + readonly delivery: Promise; + /** Record the decision the host callback is about to return to ACPX. */ + select(decision: PermissionDecision | undefined): void; + /** Reject delivery when the callback throws or its turn expires. */ + fail(error: Error): void; +} + +export interface AcpxPermissionReplyConfirmations { + observe(direction: "inbound" | "outbound", message: unknown): void; + bind(request: { sessionId: string; raw?: unknown }, signal: AbortSignal): AcpxPermissionReplyBinding; +} + +type PermissionDecision = { outcome: string; optionId?: string }; +type OfferedOption = { optionId: string; kind: string }; + +const MAX_PENDING = 128; +const DEFAULT_REPLY_TIMEOUT_MS = 10_000; + +export function createAcpxPermissionReplyConfirmations(options: { replyTimeoutMs?: number } = {}): AcpxPermissionReplyConfirmations { + const replyTimeoutMs = options.replyTimeoutMs ?? DEFAULT_REPLY_TIMEOUT_MS; + const requestIds = new Map(); + const keysById = new Map(); + const offeredById = new Map(); + const ambiguous = new Set(); + const waiting = new Map) => void>(); + const forget = (id: JsonRpcId): void => { + const key = keysById.get(id); + keysById.delete(id); + offeredById.delete(id); + if (key !== undefined && requestIds.get(key) === id) requestIds.delete(key); + }; + return { + observe(direction, message) { + const value = record(message); + const id = jsonRpcId(value.id); + if (id === undefined) return; + if (direction === "inbound" && value.method === "session/request_permission") { + const key = permissionKey(value.params); + if (key === undefined) return; + if (requestIds.has(key) || keysById.size >= MAX_PENDING) ambiguous.add(key); + else { + requestIds.set(key, id); + keysById.set(id, key); + offeredById.set(id, offeredOptions(value.params)); + } + return; + } + if (direction !== "outbound" || value.method !== undefined) return; + const settle = waiting.get(id); + waiting.delete(id); + // ACPX can answer from its own policy without the host callback. + forget(id); + settle?.(value); + }, + bind(request, signal) { + const reply = deferred(); + // A declined interaction may never await its receipt. + reply.promise.catch(() => undefined); + const key = permissionKey(request.raw) ?? permissionKey({ sessionId: request.sessionId }); + const id = key === undefined || ambiguous.has(key) ? undefined : requestIds.get(key); + const offered = id === undefined ? [] : offeredById.get(id) ?? []; + let selected: PermissionDecision | undefined | null = null; + let settled = false; + let timer: ReturnType | undefined; + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + if (id !== undefined) { + waiting.delete(id); + forget(id); + } + if (error) reply.reject(error); + else reply.resolve(); + }; + const onAbort = (): void => finish(new Error("ACP permission reply was cancelled or disconnected")); + if (id === undefined) { + finish(new Error("ACP permission request has no unique provider request id")); + } else { + waiting.set(id, (reply) => { + if (selected === null) return finish(new Error("ACP replied before the host selected a permission decision")); + if ("error" in reply || !("result" in reply)) return finish(new Error("ACP wrote an error instead of the selected permission decision")); + if (!replySelects(reply.result, selected, offered)) { + return finish(new Error("ACP changed the selected permission decision")); + } + finish(); + }); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + } + return { + delivery: reply.promise, + select(decision) { + if (settled) return; + selected = decision; + // The SDK writes the reply right after the callback returns. + timer = setTimeout(() => finish(new Error("ACP permission reply was not written")), replyTimeoutMs); + timer.unref?.(); + }, + fail(error) { + finish(error); + }, + }; + }, + }; +} + +function offeredOptions(params: unknown): OfferedOption[] { + const options = record(params).options; + if (!Array.isArray(options)) return []; + return options.map(record).flatMap(option => + typeof option.optionId === "string" && typeof option.kind === "string" ? [{ optionId: option.optionId, kind: option.kind }] : []); +} + +/** Whether the written reply is exactly the host's decision. */ +function replySelects(result: unknown, decision: PermissionDecision | undefined, offered: readonly OfferedOption[]): boolean { + const outcome = record(record(result).outcome); + if (decision === undefined || decision.outcome === "cancel") return outcome.outcome === "cancelled"; + if (outcome.outcome !== "selected" || typeof outcome.optionId !== "string") return false; + if (decision.outcome === "selected") return outcome.optionId === decision.optionId; + // ACPX resolves a decision kind to the offered option of that kind. + const ofKind = offered.filter(option => option.kind === decision.outcome); + return ofKind.length === 1 && ofKind[0]!.optionId === outcome.optionId; +} + +function permissionKey(params: unknown): string | undefined { + const value = record(params); + const toolCallId = record(value.toolCall).toolCallId; + if (typeof value.sessionId !== "string" || typeof toolCallId !== "string") return undefined; + return JSON.stringify([value.sessionId, toolCallId]); +} + +function jsonRpcId(value: unknown): JsonRpcId | undefined { + return typeof value === "string" || (typeof value === "number" && Number.isFinite(value)) ? value : undefined; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function deferred(): { promise: Promise; resolve: () => void; reject: (error: Error) => void } { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); + return { promise, resolve, reject }; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/model-verification.test.ts b/packages/paperclip-runner/src/drivers/acpx/model-verification.test.ts index 6c36e3a0774..70b750a82b2 100644 --- a/packages/paperclip-runner/src/drivers/acpx/model-verification.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/model-verification.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; -import { requireVerifiedAcpxModel } from "./model-verification.js"; +import { createAcpxEffectiveModelGate, requireVerifiedAcpxModel } from "./model-verification.js"; describe("ACPX requested model verification", () => { it.each(["claude", "codex", "pi", "grok", "cursor", "copilot"] as const)("selects and verifies an unlisted model unchanged with %s", async agent => { @@ -159,3 +159,49 @@ describe("ACPX requested model verification", () => { ).rejects.toThrow(/effective model mismatch/); }); }); + +describe("ACPX effective model gate", () => { + const config = (model: string) => [{ id: "model", category: "model", type: "select", currentValue: model, options: [] }]; + const loaded = (gate: ReturnType, result: unknown) => { + gate.observe("outbound", { jsonrpc: "2.0", id: 1, method: "session/load", params: { sessionId: "s" } }); + gate.observe("inbound", { jsonrpc: "2.0", id: 1, result }); + }; + + it("admits a prompt only after the provider reports the expected model", () => { + const gate = createAcpxEffectiveModelGate("model-a"); + gate.assertActive(); + loaded(gate, { configOptions: config("model-b") }); + expect(() => gate.assertActive()).toThrow(expect.objectContaining({ code: "ACPX_EFFECTIVE_MODEL_MISMATCH" })); + gate.observe("outbound", { jsonrpc: "2.0", id: 2, method: "session/set_config_option", params: { sessionId: "s", configId: "model", value: "model-a" } }); + gate.observe("inbound", { jsonrpc: "2.0", id: 2, result: { configOptions: config("model-a") } }); + gate.assertActive(); + }); + + it("fails closed when a load reports no model or an acknowledgement disagrees", () => { + const gate = createAcpxEffectiveModelGate("model-a"); + loaded(gate, { sessionId: "s" }); + expect(() => gate.assertActive()).toThrow(/received unverified/); + gate.observe("outbound", { jsonrpc: "2.0", id: 2, method: "session/set_config_option", params: { sessionId: "s", configId: "model", value: "model-a" } }); + gate.observe("inbound", { jsonrpc: "2.0", id: 2, result: { configOptions: config("model-c") } }); + expect(() => gate.assertActive()).toThrow(/received model-c/); + }); + + it("follows provider config updates and legacy model metadata, and resets per connection", () => { + const gate = createAcpxEffectiveModelGate("model-a"); + loaded(gate, { models: { currentModelId: "model-a" } }); + gate.assertActive(); + gate.observe("inbound", { jsonrpc: "2.0", method: "session/update", params: { sessionId: "s", update: { sessionUpdate: "config_option_update", configOptions: config("model-b") } } }); + expect(() => gate.assertActive()).toThrow(/received model-b/); + gate.connect(); + gate.assertActive(); + }); + + it("ignores failed requests and unrelated replies", () => { + const gate = createAcpxEffectiveModelGate("model-a"); + loaded(gate, { configOptions: config("model-a") }); + gate.observe("outbound", { jsonrpc: "2.0", id: 2, method: "session/set_config_option", params: { sessionId: "s", configId: "model", value: "model-b" } }); + gate.observe("inbound", { jsonrpc: "2.0", id: 2, error: { code: -32602, message: "rejected" } }); + gate.observe("inbound", { jsonrpc: "2.0", id: 99, result: { configOptions: config("model-z") } }); + gate.assertActive(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/model-verification.ts b/packages/paperclip-runner/src/drivers/acpx/model-verification.ts index 337d2612c77..a76174cab97 100644 --- a/packages/paperclip-runner/src/drivers/acpx/model-verification.ts +++ b/packages/paperclip-runner/src/drivers/acpx/model-verification.ts @@ -80,3 +80,91 @@ function normalizeVerifiedModelStatus( }, }; } + +export interface AcpxEffectiveModelGate { + /** Start fresh state for a new provider connection. */ + connect(): void; + observe(direction: "inbound" | "outbound", message: unknown): void; + /** Throws unless the provider last reported the expected model. */ + assertActive(): void; +} + +const SESSION_OPENS = new Set(["session/new", "session/load", "session/resume"]); +const MODEL_CONTROLS = new Set(["session/set_config_option", "session/set_model"]); + +/** + * Admission verifies the model on the connection it opens, but a cold prompt + * turn reconnects and loads the session again. ACPX replays the selected + * model after that load without checking what the provider acknowledged, so + * this gate reads the provider's own reports from the wire and refuses the + * prompt (ACPX calls assertActive just before writing it) unless the latest + * report is the expected model. A load that reports no model leaves the model + * unverified until a later acknowledgement does. + */ +export function createAcpxEffectiveModelGate(expectedModel: string): AcpxEffectiveModelGate { + const pending = new Map(); + let current: string | undefined; + let opened = false; + return { + connect() { + pending.clear(); + current = undefined; + opened = false; + }, + observe(direction, message) { + const value = record(message); + const id = value.id; + if (direction === "outbound") { + if ((typeof id !== "string" && typeof id !== "number") || typeof value.method !== "string") return; + if (!SESSION_OPENS.has(value.method) && !MODEL_CONTROLS.has(value.method)) return; + const params = record(value.params); + if (pending.size < 128) pending.set(id, { method: value.method, value: params.modelId ?? (params.configId === "model" ? params.value : undefined) }); + return; + } + if (value.method === "session/update") { + const update = record(record(value.params).update); + if (update.sessionUpdate === "config_option_update") { + const reported = modelFromConfigOptions(update.configOptions); + if (reported !== undefined) current = reported; + } + return; + } + if (value.method !== undefined || (typeof id !== "string" && typeof id !== "number")) return; + const request = pending.get(id); + pending.delete(id); + if (!request || value.error !== undefined) return; + const result = record(value.result); + const reported = modelFromConfigOptions(result.configOptions) ?? stringValue(record(result.models).currentModelId); + if (SESSION_OPENS.has(request.method)) { + opened = true; + current = reported; + } else if (reported !== undefined) { + current = reported; + } else if (request.method === "session/set_model" && typeof request.value === "string") { + // The legacy setter has no acknowledgement body; success applies it. + current = request.value; + } + }, + assertActive() { + if (!opened || current === expectedModel) return; + throw acpxModelVerificationError( + "ACPX_EFFECTIVE_MODEL_MISMATCH", + `ACPX effective model mismatch: expected ACP selector ${expectedModel}, received ${current ?? "unverified"}`, + ); + }, + }; +} + +function modelFromConfigOptions(value: unknown): string | undefined { + if (!Array.isArray(value)) return undefined; + const option = value.map(record).find(entry => entry.id === "model" || entry.category === "model"); + return option ? stringValue(option.currentValue) : undefined; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/prompt-receipts.test.ts b/packages/paperclip-runner/src/drivers/acpx/prompt-receipts.test.ts new file mode 100644 index 00000000000..8fe07e62670 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/prompt-receipts.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { createAcpxPromptReceiptCollector, parsePiPromptReceipt } from "./prompt-receipts.js"; +import { createGrokSessionOpenGuard } from "./grok-session-rules.js"; + +const prompt = (id: number, sessionId = "s") => ({ jsonrpc: "2.0", id, method: "session/prompt", params: { sessionId, prompt: [] } }); +const cursorReceipt = { schema: "paperclip.cursor.native-usage.v1", promptId: "12345678-1234-1234-1234-123456789abc" }; + +describe("prompt response receipts", () => { + it("collects only for Pi and Cursor", () => { + for (const agent of ["claude", "codex", "grok", "copilot", "kimi"] as const) expect(createAcpxPromptReceiptCollector(agent)).toBeNull(); + }); + + it("binds Pi cost provenance from this turn's own prompt reply", () => { + const collector = createAcpxPromptReceiptCollector("pi")!; + collector.begin("turn-1", "s"); + collector.observe("outbound", prompt(7)); + collector.observe("inbound", { jsonrpc: "2.0", id: 6, result: { usage: { _meta: { paperclipPi: { provenance: "assistant_message_receipts", costUsd: 9 } } } } }); + expect(collector.statusFields()).toEqual({}); + collector.observe("inbound", { jsonrpc: "2.0", id: 7, result: { stopReason: "end_turn", + usage: { _meta: { paperclipPi: { provenance: "assistant_message_receipts", costUsd: 0.12 } } } } }); + expect(collector.statusFields()).toEqual({ piPromptReceipt: { request_id: "turn-1", receipt: { provenance: "assistant_message_receipts", cost_usd: 0.12 } } }); + collector.begin("turn-2", "s"); + expect(collector.statusFields()).toEqual({}); + }); + + it("binds a Cursor receipt only to a settled prompt reply", () => { + const collector = createAcpxPromptReceiptCollector("cursor")!; + collector.begin("turn-1", "s"); + collector.observe("outbound", prompt(3)); + collector.observe("inbound", { jsonrpc: "2.0", id: 3, result: { stopReason: "max_tokens", _meta: { paperclipCursorUsage: cursorReceipt } } }); + expect(collector.statusFields()).toEqual({}); + collector.begin("turn-2", "s"); + collector.observe("outbound", prompt(4)); + collector.observe("inbound", { jsonrpc: "2.0", id: 4, result: { stopReason: "end_turn", _meta: { paperclipCursorUsage: cursorReceipt } } }); + expect(collector.statusFields()).toEqual({ cursorPromptReceipt: { request_id: "turn-2", receipt: cursorReceipt } }); + }); + + it("invalidates a turn with a foreign session, a second prompt, an error, or a repeated reply", () => { + const reply = (id: number) => ({ jsonrpc: "2.0", id, result: { stopReason: "end_turn", _meta: { paperclipCursorUsage: cursorReceipt } } }); + const cases: Array<(collector: NonNullable>) => void> = [ + c => { c.observe("outbound", prompt(1, "other")); c.observe("inbound", reply(1)); }, + c => { c.observe("outbound", prompt(1)); c.observe("outbound", prompt(2)); c.observe("inbound", reply(1)); }, + c => { c.observe("outbound", prompt(1)); c.observe("inbound", { jsonrpc: "2.0", id: 1, error: { code: -32603, message: "x" } }); }, + c => { c.observe("outbound", prompt(1)); c.observe("inbound", reply(1)); c.observe("inbound", reply(1)); }, + ]; + for (const run of cases) { + const collector = createAcpxPromptReceiptCollector("cursor")!; + collector.begin("turn-1", "s"); + run(collector); + expect(collector.statusFields()).toEqual({}); + } + }); + + it("accepts only proven Pi provenance with a valid cost", () => { + expect(parsePiPromptReceipt({ provenance: "assistant_message_and_compaction_receipts" })).toEqual({ provenance: "assistant_message_and_compaction_receipts" }); + for (const raw of [undefined, {}, { provenance: "other" }, { provenance: "assistant_message_receipts", costUsd: -1 }, + { provenance: "assistant_message_receipts", costUsd: Infinity }, { provenance: "assistant_message_receipts", costUsd: "1" }]) { + expect(parsePiPromptReceipt(raw)).toBeNull(); + } + }); +}); + +describe("Grok session open", () => { + it("binds the model on every open and the rules on session/new, without needing an effort", () => { + const guard = createGrokSessionOpenGuard({ rules: "Follow the task.", model: "grok-4.7" }); + const create = { method: "session/new", params: { cwd: "/w", _meta: { claudeCode: { options: {} }, systemPrompt: { append: "x" } } } }; + guard("outbound", create); + expect(create.params).toEqual({ cwd: "/w", _meta: { rules: "Follow the task.", modelId: "grok-4.7" } }); + for (const method of ["session/load", "session/resume"]) { + const reopen = { method, params: { sessionId: "s", _meta: { kept: true } } }; + guard("outbound", reopen); + expect(reopen.params._meta).toEqual({ kept: true, modelId: "grok-4.7" }); + } + const prompt = { method: "session/prompt", params: { sessionId: "s" } }; + guard("outbound", prompt); + expect(prompt.params).toEqual({ sessionId: "s" }); + const inbound = { method: "session/new", params: {} }; + guard("inbound", inbound); + expect(inbound.params).toEqual({}); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/prompt-receipts.ts b/packages/paperclip-runner/src/drivers/acpx/prompt-receipts.ts new file mode 100644 index 00000000000..a0feefdfeb8 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/prompt-receipts.ts @@ -0,0 +1,67 @@ +import type { QualifiedAcpxAgent } from "./qualified-profiles.js"; + +type RecordValue = Record; +const record = (value: unknown): RecordValue => value !== null && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : {}; +const wireId = (value: unknown): value is string | number => + (typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,239}$/.test(value)) || (Number.isSafeInteger(value) && Number(value) >= 0); + +export interface PiPromptReceipt { + request_id: string; + receipt: { provenance: "assistant_message_receipts" | "assistant_message_and_compaction_receipts"; cost_usd?: number }; +} +export interface CursorPromptReceipt { + request_id: string; + receipt: unknown; +} + +/** Pi's wrapper attaches cost provenance to the prompt response usage `_meta`. */ +export function parsePiPromptReceipt(raw: unknown): PiPromptReceipt["receipt"] | null { + const receipt = record(raw); + if (receipt.provenance !== "assistant_message_receipts" && receipt.provenance !== "assistant_message_and_compaction_receipts") return null; + const cost = receipt.costUsd; + if (cost !== undefined && !(typeof cost === "number" && Number.isFinite(cost) && cost >= 0)) return null; + return { provenance: receipt.provenance, ...(cost === undefined ? {} : { cost_usd: cost }) }; +} + +/** + * Captures the Pi or Cursor receipt carried on the active turn's own + * `session/prompt` reply. `onAcpMessage` sees the outbound prompt and its + * inbound reply, so the receipt is bound to this exact wire request instead + * of whatever ACPX last saved in its session record. A second prompt, a + * mismatched session, or conflicting replies invalidate the turn's receipt. + */ +export function createAcpxPromptReceiptCollector(agent: QualifiedAcpxAgent) { + if (agent !== "pi" && agent !== "cursor") return null; + let active: { requestId: string; sessionId: string; wireId?: string | number; invalid: boolean; result: RecordValue | null } | null = null; + return { + begin(requestId: string, sessionId: string) { + active = { requestId, sessionId, invalid: false, result: null }; + }, + observe(direction: "inbound" | "outbound", value: unknown) { + if (!active || active.invalid) return; + const message = record(value); + if (direction === "outbound") { + if (message.method !== "session/prompt" || record(message.params).sessionId !== active.sessionId) return; + if (active.wireId !== undefined || !wireId(message.id)) { active.invalid = true; return; } + active.wireId = message.id; + return; + } + if (active.wireId === undefined || message.id !== active.wireId || message.method !== undefined) return; + if (message.error !== undefined || active.result !== null) { active.invalid = true; return; } + active.result = structuredClone(record(message.result)); + }, + /** Status fields for the active (or last) turn; empty when no valid receipt exists. */ + statusFields(): { piPromptReceipt?: PiPromptReceipt; cursorPromptReceipt?: CursorPromptReceipt } { + if (!active || active.invalid || active.result === null) return {}; + const result = active.result; + if (agent === "pi") { + const receipt = parsePiPromptReceipt(record(record(result.usage)._meta).paperclipPi); + return receipt ? { piPromptReceipt: { request_id: active.requestId, receipt } } : {}; + } + // Cursor's receipt is diagnostic and only meaningful for a settled turn. + if (result.stopReason !== "end_turn" && result.stopReason !== "cancelled") return {}; + const receipt = record(result._meta).paperclipCursorUsage; + return receipt === undefined ? {} : { cursorPromptReceipt: { request_id: active.requestId, receipt: structuredClone(receipt) } }; + }, + }; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/provider-failure.ts b/packages/paperclip-runner/src/drivers/acpx/provider-failure.ts index d66fb3c27cf..9439f9cf41b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/provider-failure.ts +++ b/packages/paperclip-runner/src/drivers/acpx/provider-failure.ts @@ -33,6 +33,41 @@ const ACP_AUTH_REQUIRED = -32000; /** The typed AIR session failure ACPX reports beside its generic terminal error. */ export type AcpxTerminalSessionFailure = { category: string; title?: string; details?: string }; +const TYPED_SESSION_FAILURE_CATEGORIES = new Set(["connection", "access", "limit", "service", "request", "unknown"]); + +/** Advertised on initialize so ACP agents report terminal failures as typed JetBrains AIR metadata. */ +export function acpxTypedSessionFailureClientCapabilities( + extra?: Record, +): Record { + const meta = extra?._meta !== null && typeof extra?._meta === "object" && !Array.isArray(extra._meta) + ? extra._meta as Record + : {}; + return { + ...structuredClone(extra ?? {}), + _meta: { jetbrains: { air: { version: 1, capabilities: ["sessionFailure"] } }, ...structuredClone(meta) }, + }; +} + +/** + * Read a typed terminal failure from the prompt response `_meta`, which ACPX + * returns on the turn result. The agent still answers with a normal stop + * reason, so the caller must fail the turn itself. + */ +export function acpxTypedTerminalSessionFailure(meta: unknown): AcpxTerminalSessionFailure | null { + const object = (value: unknown): Record | null => + value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : null; + const air = object(object(object(meta)?.jetbrains)?.air); + if (!air || !Number.isInteger(air.version) || Number(air.version) < 1) return null; + const failure = object(air.sessionFailure); + if (!failure || failure.severity !== "error") return null; + // Keep complete provider text; consumers redact before bounding it. + return { + category: typeof failure.category === "string" && TYPED_SESSION_FAILURE_CATEGORIES.has(failure.category) ? failure.category : "unknown", + ...(typeof failure.title === "string" ? { title: failure.title } : {}), + ...(typeof failure.details === "string" ? { details: failure.details } : {}), + }; +} + /** * AIR's `limit` category also covers context, turn and configured budget * limits. Only an exhausted account allowance is a provider limit; the diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts index 6122e64a810..d0d775624ec 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts @@ -59,6 +59,20 @@ describe("ACPX recovery identity", () => { expect(() => verifyExpectedAcpxIdentity({ ...priorExpected, profileDigest: next.commandDigest }, next, record)).toThrow(/persisted runtime record/); }); + it("keeps a session saved under ACPX 0.13.1 after the ACPX upgrade", async () => { + const fixture = await recoveryFixture(); + const current = await createAcpxRecoveryBinding(fixture.input); + const previous = await createAcpxRecoveryBinding({ ...fixture.input, + profile: { ...fixture.input.profile, acpxVersion: "0.13.1" as never } }); + // The same session key finds the saved ACPX record, and its persisted + // identity record still verifies. + expect(current.profileSessionKey).toBe(previous.profileSessionKey); + expect(current.profileDigest).toBe(previous.profileDigest); + const expected = { ...fixture.expected, profileDigest: previous.commandDigest }; + const record = createAcpxIdentityRecord(expected, previous); + expect(acpxProviderSessionIdentity(record, current).acpxRecordId).toBe(record.acpxRecordId); + }); + it("binds Cursor mode on recovery and rejects missing or changed persisted mode", async () => { const fixture = await recoveryFixture(); const input = { ...fixture.input, profile: resolveQualifiedAcpxProfile("cursor", fixture.input.requestedModel), mode: "agent" }; diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts index bb05e194ff6..49354c33e08 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts @@ -51,6 +51,12 @@ export interface AcpxIdentityRecord { providerLifetimeFenceCandidates: readonly [number, number, number]; } +// ACPX upgrades carry saved sessions forward: the current ACPX loads the +// session records earlier releases wrote. Session keys and persisted identity +// records first hashed ACPX 0.13.1, so the identity keeps that value and they +// still match after an upgrade. +const SESSION_IDENTITY_ACPX_VERSION = "0.13.1"; + export async function createAcpxRecoveryBinding(input: { runtimeDirectory: string; normalizedSessionId: string; @@ -91,7 +97,7 @@ export async function createAcpxRecoveryBinding(input: { canonicalJson({ driverKind: input.profile.driverKind, protocolVersion: input.profile.protocolVersion, - acpxVersion: input.profile.acpxVersion, + acpxVersion: SESSION_IDENTITY_ACPX_VERSION, agent: input.profile.agent, agentProfileVersion: input.profile.agentProfileVersion, agentServerPackage: input.profile.agentServerPackage, diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 17412a3ae5b..ed5b229e85a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -23,7 +23,6 @@ import { prepareStdioMcpBindings } from "../stdio-mcp.js"; import { nativeMcpLaunchBinding } from "../native-mcp.js"; import type { - AcpElicitationHandler, AcpRuntimeOptions, AcpRuntimeEvent, AcpRuntimeTurnResult, @@ -50,6 +49,7 @@ import { type VerifiedAcpxInstallation, } from "./installation-integrity.js"; import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js"; +import type { AcpxHostElicitationHandler, AcpxHostExtensionRequestHandler, AcpxHostPermissionHandler } from "./interaction-delivery.js"; import { requireVerifiedAcpxModel, type AcpxModelStatus, @@ -136,9 +136,9 @@ export interface AcpxRuntimeTurnInput { text: string; requestId: string; signal?: AbortSignal; - onElicitation?: AcpElicitationHandler; - onPermissionRequest?: AcpRuntimeOptions["onPermissionRequest"]; - onExtensionRequest?: AcpRuntimeOptions["onExtensionRequest"]; + onElicitation?: AcpxHostElicitationHandler; + onPermissionRequest?: AcpxHostPermissionHandler; + onExtensionRequest?: AcpxHostExtensionRequestHandler; onExtensionNotification?: AcpRuntimeOptions["onExtensionNotification"]; /** Receives a harness-native proposed plan that Paperclip, not the harness, approves. */ onPlanProposal?: (proposal: AcpxPlanProposal) => void; diff --git a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.test.ts b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.test.ts index 3f22325228a..ad590d6bed3 100644 --- a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.test.ts @@ -7,6 +7,7 @@ import { frameAcpxToolClassification, parseAcpxSidecarRequest, safeSidecarText, + acpxPlanSnapshot, sanitizeAcpxPlanEntries, stringifyAcpxSidecarFrame, } from "./sidecar-protocol.js"; @@ -173,6 +174,19 @@ describe("ACPX sidecar request parsing", () => { }); describe("ACPX sidecar structured plans", () => { + it("reads plan snapshots from ACPX plan-tagged status events", () => { + const entries = [{ content: "Ship", status: "in_progress", priority: "high" }]; + expect(acpxPlanSnapshot({ type: "status", text: "plan: Ship", tag: "plan", entries })).toEqual([ + { content: "Ship", status: "in_progress", priority: "high" }, + ]); + // An explicit empty snapshot clears the displayed plan. + expect(acpxPlanSnapshot({ type: "status", text: "plan updated", tag: "plan", entries: [] })).toEqual([]); + // A summary without usable entries, and other status tags, never replace the plan. + expect(acpxPlanSnapshot({ type: "status", text: "plan: Ship", tag: "plan" })).toBeNull(); + expect(acpxPlanSnapshot({ type: "status", text: "usage", tag: "usage_update", entries })).toBeNull(); + expect(acpxPlanSnapshot({ type: "plan", tag: "plan", entries })).toBeNull(); + }); + it("preserves every valid ordered entry while bounding and sanitizing the snapshot", () => { const entries = sanitizeAcpxPlanEntries([ { content: " Inspect ", status: "completed", priority: "high" }, diff --git a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts index fd36c5aa8b3..c31f5f6c70d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts +++ b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts @@ -295,6 +295,17 @@ export function sanitizeAcpxPlanEntries(value: unknown): Array<{ }); } +/** + * Sanitized plan snapshot from an ACPX runtime event, or null when the event + * does not replace the plan. ACPX reports plans as `status` events tagged + * `plan`; an `entries` array (including [] to clear) is a full snapshot. + */ +export function acpxPlanSnapshot(event: unknown): ReturnType | null { + const candidate = record(event); + if (candidate.type !== "status" || candidate.tag !== "plan" || !Array.isArray(candidate.entries)) return null; + return sanitizeAcpxPlanEntries(candidate.entries); +} + export function record(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) diff --git a/packages/paperclip-runner/src/drivers/acpx/turn-dispatch-journal.test.ts b/packages/paperclip-runner/src/drivers/acpx/turn-dispatch-journal.test.ts index b65de7a8c6e..97160f0add8 100644 --- a/packages/paperclip-runner/src/drivers/acpx/turn-dispatch-journal.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/turn-dispatch-journal.test.ts @@ -130,13 +130,16 @@ require('node:readline').createInterface({ input: process.stdin }).on('line', li });`; const children: ChildProcess[] = []; let armed = false; + // ACPX 0.22 ignores observer errors. The adapter keeps the failed write and + // throws it from the turn's assertActive, which ACPX calls before the write. + let failure: Error | null = null; const runtime = createAcpRuntime({ cwd: dir, agentRegistry: createAgentRegistry({ overrides: { claude: "fixture" } }), sessionStore: createRuntimeStore({ stateDir: join(dir, "state") }), permissionMode: "deny-all", onAcpMessage: (direction, value) => { - if (armed && direction === "outbound" && isAcpPromptRequest(value)) throw new Error("ENOSPC: dispatch record not written"); + if (armed && direction === "outbound" && isAcpPromptRequest(value)) failure = new Error("ENOSPC: dispatch record not written"); }, spawnAgent: () => { const child = spawn(process.execPath, ["-e", peer], { cwd: dir, env: { ...process.env, SEEN: seen }, stdio: ["pipe", "pipe", "pipe"] }); @@ -147,7 +150,8 @@ require('node:readline').createInterface({ input: process.stdin }).on('line', li try { const handle = await runtime.ensureSession({ sessionKey: "journal-refused", agent: "claude", mode: "persistent", cwd: dir }); armed = true; - const turn = runtime.startTurn({ handle, text: "fixture", mode: "prompt", requestId: "turn-refused", timeoutMs: 5_000 }); + const turn = runtime.startTurn({ handle, text: "fixture", mode: "prompt", requestId: "turn-refused", timeoutMs: 5_000, + assertActive: () => { if (failure) throw failure; } }); const drain = (async () => { for await (const _event of turn.events) { /* drain */ } })().catch(() => undefined); const result = await turn.result.catch((error: unknown) => ({ status: "rejected", error })); await drain; diff --git a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.test.ts b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.test.ts index 3e6b51999c9..991d581b0fa 100644 --- a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { persistedAcpxTurnUsage, - parseCursorPromptUsage, + parseCursorNativeUsageReceipt, persistedCursorUsageNotice, acpxUsageEstimateNotice, qualifiedAcpxUsageBreakdown, @@ -135,9 +135,11 @@ describe("persisted terminal ACPX usage", () => { }); describe("Pi prompt accounting authority", () => { - const after = { lastRequestId: "turn", usageCost: { amount: 999, currency: "USD" }, requestTokenUsage: { - message: { input_tokens: 12, paperclip_pi: { provenance: "assistant_message_receipts", cost_usd: 0.123 } }, - } }; + // The receipt is captured from this turn's own session/prompt reply. + const after = { lastRequestId: "turn", usageCost: { amount: 999, currency: "USD" }, + requestTokenUsage: { message: { input_tokens: 12 } }, + piPromptReceipt: { request_id: "turn", receipt: { provenance: "assistant_message_receipts", cost_usd: 0.123 } } }; + const withReceipt = (receipt: unknown, requestId = "turn") => ({ ...after, piPromptReceipt: { request_id: requestId, receipt } }); it("preserves an exact new Pi receipt estimate without charging it as billed spend", () => { const usage = persistedAcpxTurnUsage({}, after, "turn", "pi")!; expect(usage.cost).toBeUndefined(); @@ -147,24 +149,21 @@ describe("Pi prompt accounting authority", () => { }); expect(persistedAcpxTurnUsage(after, after, "turn", "pi")).toBeNull(); }); - it("ignores foreign, missing, invalid and unproven estimates", () => { + it("ignores foreign, missing, invalid, unproven, and other-turn estimates", () => { expect(persistedAcpxTurnUsage({}, after, "turn", "copilot")?.pricingEstimateUsd).toBeUndefined(); - for (const receipt of [{}, { provenance: "other", cost_usd: 2 }, - { provenance: "assistant_message_receipts", cost_usd: -1 }, - { provenance: "assistant_message_receipts", cost_usd: Infinity }]) { - const usage = persistedAcpxTurnUsage({}, { ...after, requestTokenUsage: { - message: { input_tokens: 12, paperclip_pi: receipt }, - } }, "turn", "pi")!; + for (const current of [withReceipt({}), withReceipt({ provenance: "other", cost_usd: 2 }), + withReceipt({ provenance: "assistant_message_receipts", cost_usd: -1 }), + withReceipt({ provenance: "assistant_message_receipts", cost_usd: Infinity }), + withReceipt({ provenance: "assistant_message_receipts", cost_usd: 0.5 }, "earlier-turn")]) { + const usage = persistedAcpxTurnUsage({}, current, "turn", "pi")!; expect(usage.cost).toBeUndefined(); expect(acpxUsageEstimateNotice(usage, "turn:pricing")).toBeNull(); } }); it("preserves compaction receipt provenance without treating estimates as billing", () => { - const usage = persistedAcpxTurnUsage({}, { ...after, requestTokenUsage: { - message: { input_tokens: 12, paperclip_pi: { - provenance: "assistant_message_and_compaction_receipts", cost_usd: 0.25, - } }, - } }, "turn", "pi")!; + const usage = persistedAcpxTurnUsage({}, withReceipt({ + provenance: "assistant_message_and_compaction_receipts", cost_usd: 0.25, + }), "turn", "pi")!; expect(usage.cost).toBeUndefined(); expect(usage.usageProvenance).toBe("pi_assistant_message_and_compaction_receipts"); expect(acpxUsageEstimateNotice(usage, "turn:pricing")?.payload).toMatchObject({ @@ -175,14 +174,16 @@ describe("Pi prompt accounting authority", () => { describe("partial native Cursor observations", () => { - const receipt = () => ({ request_id: "request-1", prompt_message_id: "message-1", receipt: { + const nativeReceipt = () => ({ schema: "paperclip.cursor.native-usage.v1", source: "native_turn_ended", promptId: "12345678-1234-1234-1234-123456789abc", completeness: "partial", reasons: ["native_counter_semantics_unverified", "child_run_attribution_unverified"], observations: [{ invocationId: "invocation-1", role: "parent", nativeRun: 1, sequence: 1, counters: { inputTokens: 12, outputTokens: 3 } }], limits: { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 }, truncated: false, - } }); - const before = { lastRequestId: "old-request", promptMessageIds: ["old-message"], requestTokenUsage: {} }; - const after = () => ({ lastRequestId: "request-1", promptMessageIds: ["old-message", "message-1"], cursorPromptUsage: receipt(), requestTokenUsage: {} }); + }); + // Bound by the runtime adapter to this turn's own session/prompt reply. + const bound = (receipt: unknown = nativeReceipt(), requestId = "request-1") => ({ request_id: requestId, receipt }); + const before = { lastRequestId: "old-request", requestTokenUsage: {} }; + const after = () => ({ lastRequestId: "request-1", cursorPromptReceipt: bound(), requestTokenUsage: {} }); it("emits only a bounded unsummed partial notice, with no accounting side effects", () => { const current = after(), original = structuredClone(current); const notice = persistedCursorUsageNotice(before, current, "request-1", "cursor", "turn:cursor-native-usage"); @@ -193,36 +194,31 @@ describe("partial native Cursor observations", () => { expect(current).toEqual(original); expect(persistedAcpxTurnUsage(before, current, "request-1", "cursor")).toBeNull(); }); - it("rejects stale, replaced, foreign, missing, and duplicate message bindings", () => { + it("rejects stale, foreign, missing, and repeated receipts", () => { for (const agent of [null, "codex", "copilot", "pi"] as const) expect(persistedCursorUsageNotice(before, after(), "request-1", agent, "notice")).toBeNull(); for (const current of [ { ...after(), lastRequestId: "wrong" }, - { ...after(), cursorPromptUsage: { ...receipt(), request_id: "wrong" } }, - { ...after(), promptMessageIds: [] }, - { ...after(), promptMessageIds: ["message-1", "message-1"] }, + { ...after(), cursorPromptReceipt: bound(nativeReceipt(), "wrong") }, + { ...after(), cursorPromptReceipt: undefined }, ]) expect(persistedCursorUsageNotice(before, current, "request-1", "cursor", "notice")).toBeNull(); - for (const prior of [after(), { ...before, promptMessageIds: ["message-1"] }, { ...before, cursorPromptUsage: { ...receipt(), request_id: "old-request", prompt_message_id: "old-message" } }]) + for (const prior of [after(), { ...before, cursorPromptReceipt: bound(nativeReceipt(), "old-request") }]) expect(persistedCursorUsageNotice(prior, after(), "request-1", "cursor", "notice")).toBeNull(); }); it("drops malformed or excessive metadata without retaining unknown/free-text fields", () => { - for (const value of [null, {}, { ...receipt(), extra: "secret" }, { ...receipt(), request_id: "x".repeat(241) }, - { ...receipt(), receipt: { ...receipt().receipt, extra: "secret" } }, - { ...receipt(), receipt: { ...receipt().receipt, reasons: ["secret"] } }, - { ...receipt(), receipt: { ...receipt().receipt, observations: [{ ...receipt().receipt.observations[0], counters: { inputTokens: -1 } }] } }, - ]) expect(parseCursorPromptUsage(value)).toBeNull(); + for (const value of [null, {}, { ...nativeReceipt(), extra: "secret" }, + { ...nativeReceipt(), reasons: ["secret"] }, + { ...nativeReceipt(), observations: [{ ...nativeReceipt().observations[0], counters: { inputTokens: -1 } }] }, + ]) expect(parseCursorNativeUsageReceipt(value)).toBeNull(); const circular: Record = {}; circular.self = circular; - expect(parseCursorPromptUsage(circular)).toBeNull(); + expect(parseCursorNativeUsageReceipt(circular)).toBeNull(); }); it("retains all legal observations within canonical per-detail bounds", () => { - const value = receipt(); - value.receipt.observations = Array.from({ length: 64 }, (_, i) => ({ invocationId: `invocation-${i + 1}`, role: "parent", nativeRun: 1, sequence: 1, counters: { inputTokens: 12, outputTokens: 3 } })); - const notice = persistedCursorUsageNotice(before, { ...after(), cursorPromptUsage: value }, "request-1", "cursor", "notice")!; + const value = nativeReceipt(); + value.observations = Array.from({ length: 64 }, (_, i) => ({ invocationId: `invocation-${i + 1}`, role: "parent", nativeRun: 1, sequence: 1, counters: { inputTokens: 12, outputTokens: 3 } })); + const notice = persistedCursorUsageNotice(before, { ...after(), cursorPromptReceipt: bound(value) }, "request-1", "cursor", "notice")!; const details = notice.payload.details as {name:string;value:string}[]; expect(details.length).toBeLessThanOrEqual(64); expect(details.every(d => Buffer.byteLength(d.value) <= 4000)).toBe(true); - expect(details.filter(d => d.name.startsWith("Native observations")).flatMap(d => JSON.parse(d.value))).toEqual(value.receipt.observations); - const maxIds = { ...receipt(), request_id: "r".repeat(240), prompt_message_id: "m".repeat(240) }; - expect(parseCursorPromptUsage(maxIds)).not.toBeNull(); - expect(Buffer.byteLength(JSON.stringify(maxIds)) - Buffer.byteLength(JSON.stringify(maxIds.receipt))).toBeLessThanOrEqual(640); + expect(details.filter(d => d.name.startsWith("Native observations")).flatMap(d => JSON.parse(d.value))).toEqual(value.observations); }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts index 6640ce97efa..33f96d0c794 100644 --- a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts +++ b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts @@ -54,7 +54,9 @@ export function persistedAcpxTurnUsage( ); if (added.length !== 1) return null; const usage = record(receipts[added[0]!]); - const piReceipt = agent === "pi" ? record(usage.paperclip_pi) : {}; + // Pi's cost provenance comes from this turn's own prompt reply, not ACPX's record. + const piPrompt = record(current.piPromptReceipt); + const piReceipt = agent === "pi" && piPrompt.request_id === requestId ? record(piPrompt.receipt) : {}; const piReceiptVerified = piReceipt.provenance === "assistant_message_receipts" || piReceipt.provenance === "assistant_message_and_compaction_receipts"; const estimate = piReceiptVerified && typeof piReceipt.cost_usd === "number" @@ -100,69 +102,4 @@ export function acpxUsageEstimateNotice(usage: Record, itemId: }; } -type CursorPromptUsage = { - request_id: string; prompt_message_id: string; - receipt: { - schema: "paperclip.cursor.native-usage.v1"; source: "native_turn_ended"; promptId: string; - completeness: "partial"; reasons: string[]; truncated: boolean; - limits: { maxObservations: 64; maxInvocations: 64; maxBytes: 16384 }; - observations: Array<{ invocationId: string; role: "parent" | "child"; nativeRun: number; sequence: number; counters: Record }>; - }; -}; -/** Closed diagnostic receipt. Counter semantics and billing are always unverified. */ -export function parseCursorPromptUsage(raw: unknown): CursorPromptUsage | null { - try { - const text = JSON.stringify(raw); - if (!text || Buffer.byteLength(text, "utf8") > 16384) return null; - const v = JSON.parse(text) as CursorPromptUsage; - const object = (x: unknown): x is object => x !== null && typeof x === "object" && !Array.isArray(x); - const exact = (x: object, keys: readonly string[]) => Object.keys(x).length === keys.length && keys.every(k => Object.hasOwn(x, k)); - const id = (x: unknown) => typeof x === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,239}$/.test(x); - if (!object(v) || !exact(v, ["request_id", "prompt_message_id", "receipt"]) || !id(v.request_id) || !id(v.prompt_message_id)) return null; - const r = v.receipt; - if (!object(r) || !exact(r, ["schema", "source", "promptId", "completeness", "reasons", "observations", "limits", "truncated"])) return null; - if (r.schema !== "paperclip.cursor.native-usage.v1" || r.source !== "native_turn_ended" || r.completeness !== "partial" || typeof r.promptId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(r.promptId) || typeof r.truncated !== "boolean") return null; - const reasons = ["native_counter_semantics_unverified", "native_counters_missing", "invalid_native_counter", "multiple_terminal_observations", "native_terminal_not_observed", "observation_limit_reached", "child_run_attribution_unverified"]; - if (!Array.isArray(r.reasons) || r.reasons.length > reasons.length || !r.reasons.includes(reasons[0]!) || new Set(r.reasons).size !== r.reasons.length || !r.reasons.every(x => reasons.includes(x))) return null; - if (!object(r.limits) || !exact(r.limits, ["maxObservations", "maxInvocations", "maxBytes"]) || r.limits.maxObservations !== 64 || r.limits.maxInvocations !== 64 || r.limits.maxBytes !== 16384 || !Array.isArray(r.observations) || r.observations.length > 64) return null; - const fields = ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"]; - const invocations = new Map(); - for (const o of r.observations) { - if (!object(o) || !exact(o, ["invocationId", "role", "nativeRun", "sequence", "counters"]) || typeof o.invocationId !== "string" || !/^invocation-([1-9]|[1-5][0-9]|6[0-4])$/.test(o.invocationId) || (o.role !== "parent" && o.role !== "child") || !Number.isSafeInteger(o.nativeRun) || o.nativeRun < 1 || !Number.isSafeInteger(o.sequence) || o.sequence < 1 || !object(o.counters)) return null; - if (!Object.entries(o.counters).every(([k, n]) => fields.includes(k) && Number.isSafeInteger(n) && n >= 0)) return null; - const previous = invocations.get(o.invocationId); - if (previous && (previous.role !== o.role || previous.nativeRun !== o.nativeRun || o.sequence <= previous.sequence)) return null; - invocations.set(o.invocationId, { role: o.role, nativeRun: o.nativeRun, sequence: o.sequence }); - } - return v; - } catch { return null; } -} - -/** Partial native observations never enter usage_update, aggregate tokens, or USD. */ -export function persistedCursorUsageNotice(before: unknown, after: unknown, requestId: string, agent: QualifiedAcpxAgent | null, itemId: string): CanonicalProviderEvent | null { - if (agent !== "cursor") return null; - const current = record(after), prior = record(before); - if (current.lastRequestId !== requestId || prior.lastRequestId === requestId) return null; - const receipt = parseCursorPromptUsage(current.cursorPromptUsage); - if (!receipt || receipt.request_id !== requestId) return null; - const previous = parseCursorPromptUsage(prior.cursorPromptUsage); - if (previous && (previous.request_id === receipt.request_id || previous.prompt_message_id === receipt.prompt_message_id || previous.receipt.promptId === receipt.receipt.promptId)) return null; - // A message must have been created by this turn; replacing metadata on an old message is not fresh evidence. - if (!Array.isArray(current.promptMessageIds) || current.promptMessageIds.filter(id => id === receipt.prompt_message_id).length !== 1 - || !Array.isArray(prior.promptMessageIds) || prior.promptMessageIds.includes(receipt.prompt_message_id)) return null; - const details = [ - { name: "Provenance", value: "Cursor native turnEnded observations; partial, unsummed, unverified counter semantics" }, - { name: "Partial reasons", value: receipt.receipt.reasons.join(", ") }, - { name: "Collector truncated", value: String(receipt.receipt.truncated) }, - ...Array.from({ length: Math.ceil(receipt.receipt.observations.length / 8) }, (_, group) => ({ - name: `Native observations ${group * 8 + 1}-${Math.min((group + 1) * 8, receipt.receipt.observations.length)}`, - value: JSON.stringify(receipt.receipt.observations.slice(group * 8, (group + 1) * 8)), - })), - ]; - // Eight closed observations fit within each 4,000-byte detail, without dropping any counters. - return { - eventType: "provider.notice.recorded", itemId, - payload: { schema: "paperclip.provider.notice.v1", noticeId: itemId, severity: "info", category: "cursor_native_usage_observed", scope: "turn", recoverable: true, userActionable: false, - summary: "Cursor reported partial native counters. These observations are not authoritative token usage or billing cost.", details }, - }; -} +export { parseCursorNativeUsageReceipt, persistedCursorUsageNotice } from "./cursor-usage-receipt.js"; diff --git a/packages/paperclip-runner/src/issue-thread/fixtures.ts b/packages/paperclip-runner/src/issue-thread/fixtures.ts index acb049f49de..6cb1494b491 100644 --- a/packages/paperclip-runner/src/issue-thread/fixtures.ts +++ b/packages/paperclip-runner/src/issue-thread/fixtures.ts @@ -1168,7 +1168,7 @@ const PROVIDER_SCENARIOS: Record { describe("Cursor partial usage notice capture", () => { function notice(threadId: string, turnId: string): CodexRpcNotification { - const canonical = persistedCursorUsageNotice({ promptMessageIds: [] }, { - lastRequestId: "request-1", promptMessageIds: ["prompt-1"], cursorPromptUsage: { - request_id: "request-1", prompt_message_id: "prompt-1", receipt: { + const canonical = persistedCursorUsageNotice({}, { + lastRequestId: "request-1", cursorPromptReceipt: { + request_id: "request-1", receipt: { schema: "paperclip.cursor.native-usage.v1", source: "native_turn_ended", promptId: "12345678-1234-1234-1234-123456789abc", completeness: "partial", reasons: ["native_counter_semantics_unverified", "child_run_attribution_unverified"], diff --git a/packages/paperclip-runner/src/protocol/provider-descriptor.test.ts b/packages/paperclip-runner/src/protocol/provider-descriptor.test.ts index d6d1f3d5b6e..1bd9bf114c9 100644 --- a/packages/paperclip-runner/src/protocol/provider-descriptor.test.ts +++ b/packages/paperclip-runner/src/protocol/provider-descriptor.test.ts @@ -9,7 +9,7 @@ const validate = new Ajv2020({ allErrors: true, strict: false }).compile(provide describe("provider runtime descriptor", () => { it("validates negotiated turn controls in descriptors and capability events", () => { const descriptor = { provider: "acpx", driver: "acpx_runtime", agent: "pi", model: "explicit-model", - requestedModel: "explicit-model", executionKind: "local_process", providerVersion: "0.13.1", + requestedModel: "explicit-model", executionKind: "local_process", providerVersion: "0.22.0", agentProcessId: 42, acpProtocolVersion: 1, agentServerPackage: "pi-acp", agentServerVersion: "0.0.33", acpxRecordId: "record", turnControls: { steering: true, queuedFollowUp: true } }; expect(validate(descriptor), JSON.stringify(validate.errors)).toBe(true); @@ -49,7 +49,7 @@ describe("provider runtime descriptor", () => { model: "claude-sonnet-5", requestedModel: "claude-sonnet-5", executionKind: "local_process", - providerVersion: "0.13.1", + providerVersion: "0.22.0", providerSessionId: "claude-session-1", processId: 41001, agentProcessId: 41002, diff --git a/packages/paperclip-runner/src/provider-events.test.ts b/packages/paperclip-runner/src/provider-events.test.ts index d783541e103..07e66d90876 100644 --- a/packages/paperclip-runner/src/provider-events.test.ts +++ b/packages/paperclip-runner/src/provider-events.test.ts @@ -593,6 +593,26 @@ describe("provider-neutral events", () => { expect(event.payload.complete).toBe(false); }); + it("maps an ACPX 0.22 plan status event, including a cleared plan, to a plan update", () => { + const [updated] = canonicalProviderEventsFromAcpxRuntimeEvent( + { type: "status", tag: "plan", text: "plan", entries: [ + { content: "Inspect", status: "completed", priority: "high" }, + { content: "Verify", status: "pending", priority: "low" }, + ] } as never, + "fallback", + "turn-acp", + ); + expect(updated).toMatchObject({ eventType: "plan.updated", itemId: "turn-acp", payload: { planId: "turn-acp", complete: false } }); + expect((updated!.payload as { steps: unknown[] }).steps).toHaveLength(2); + const [cleared] = canonicalProviderEventsFromAcpxRuntimeEvent( + { type: "status", tag: "plan", text: "plan", entries: [] } as never, + "fallback", + "turn-acp", + ); + expect(cleared).toMatchObject({ eventType: "plan.updated", payload: { planId: "turn-acp" } }); + expect((cleared!.payload as { steps: unknown[] }).steps).toEqual([]); + }); + it("preserves every structured ACPX plan entry in a stable turn snapshot", () => { const event = canonicalProviderEventsFromAcpxRuntimeEvent( { diff --git a/packages/paperclip-runner/src/provider-events.ts b/packages/paperclip-runner/src/provider-events.ts index cb321859119..98911bacc20 100644 --- a/packages/paperclip-runner/src/provider-events.ts +++ b/packages/paperclip-runner/src/provider-events.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { redactCodexDiagnostic } from "./drivers/codex/diagnostic-redaction.js"; import { claudeHookCanonicalEvent, codexHookPayload, codexSafetyReviewPayload } from "./governance-events.js"; import type { AcpxSubagentAnnotations, AcpxSubagentIdentity } from "./claude-subagent-lineage.js"; +import { acpxPlanSnapshot } from "./drivers/acpx/sidecar-protocol.js"; import { toolFileChangesFromAcpContent, toolFileChangesFromCodexChanges, @@ -1587,6 +1588,9 @@ export function canonicalProviderEventsFromAcpxRuntimeEvent( turnId?: string, context: CanonicalProviderEventContext = {}, ): CanonicalProviderEvent[] { + // ACPX 0.22 reports a plan as a `status` event tagged `plan`. + const planEntries = acpxPlanSnapshot(event); + if (planEntries) event = { type: "plan", entries: planEntries } as AcpRuntimeEventShape; const runtimeType = text(record(event).type); if ( !["text_delta", "status", "tool_call", "plan", "error", "done"].includes( diff --git a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs index c70486fbdac..0496f5fd763 100644 --- a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs @@ -19,7 +19,7 @@ const workspace = await readFile( "utf8", ); const acpxPatch = await readFile( - new URL("../../../patches/acpx@0.13.1.patch", import.meta.url), + new URL("../../../patches/acpx@0.22.0.patch", import.meta.url), "utf8", ); const codexPatch = await readFile( @@ -61,7 +61,7 @@ test("the runner pins every qualified ACPX production dependency", () => { "@github/copilot-linux-x64": "1.0.88", }); assert.equal(runnerPackage.dependencies.node, undefined); - assert.equal(runnerPackage.dependencies.acpx, "0.13.1"); + assert.equal(runnerPackage.dependencies.acpx, "0.22.0"); assert.equal( runnerPackage.dependencies["@agentclientprotocol/codex-acp"], "1.6.2", @@ -98,8 +98,8 @@ test("the package exposes only the reviewed runner CLI binaries", () => { test("old and new pnpm configuration both apply the exact runtime patches", () => { assert.equal( - rootPackage.pnpm.patchedDependencies["acpx@0.13.1"], - "patches/acpx@0.13.1.patch", + rootPackage.pnpm.patchedDependencies["acpx@0.22.0"], + "patches/acpx@0.22.0.patch", ); assert.equal( rootPackage.pnpm.patchedDependencies[ @@ -113,7 +113,7 @@ test("old and new pnpm configuration both apply the exact runtime patches", () = ], "patches/@agentclientprotocol__codex-acp@1.6.2.patch", ); - assert.match(workspace, /acpx@0\.13\.1: patches\/acpx@0\.13\.1\.patch/); + assert.match(workspace, /acpx@0\.22\.0: patches\/acpx@0\.22\.0\.patch/); assert.match( workspace, /codex-acp@1\.6\.2["']: patches\/@agentclientprotocol__codex-acp@1\.6\.2\.patch/, @@ -131,10 +131,11 @@ test("old and new pnpm configuration both apply the exact runtime patches", () = assert.match(codexPatch, /\+ "@openai\/codex": "0\.160\.0"/); }); -test("the ACPX patch preserves launch-only state and verified spawning", () => { +test("the ACPX patch preserves verified spawning and leaves the environment to the host", () => { + // Runner and adapter-utils set the agent's environment in spawnAgent. + const added = acpxPatch.split("\n").filter((line) => line.startsWith("+")).join("\n"); + assert.doesNotMatch(added, /spawnEnvironment|resolveAgentEnvironment/); for (const token of [ - "spawnEnvironment", - "spawnCwd", "spawnAgent", "SpawnOptionsWithoutStdio", "this.options.spawnAgent", @@ -143,49 +144,6 @@ test("the ACPX patch preserves launch-only state and verified spawning", () => { } }); -test("the ACPX patch fails closed on an invalid spawn environment", () => { - for (const token of [ - "isPlainStringEnvironment", - "Object.getPrototypeOf(value)", - 'Object.values(value).every((entry) => typeof entry === "string")', - "spawnEnvironment !== void 0", - "sourceEnvironment = spawnEnvironment()", - "ACPX spawn environment must be a plain record of string values", - ]) { - assert.match( - acpxPatch, - new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), - ); - } - assert.doesNotMatch(acpxPatch, /spawnEnvironment\?\.\(\)/); - assert.doesNotMatch( - acpxPatch, - /spawnEnvironment \? \{ \.\.\.spawnEnvironment \} : \{ \.\.\.process\.env \}/, - ); -}); - -test("authentication rejects invalid isolated environments without host fallback", () => { - const addedSource = acpxPatch.split("\n") - .filter((line) => line.startsWith("+") && !line.startsWith("+++")) - .map((line) => line.slice(1)).join("\n"); - const start = addedSource.indexOf("function isPlainStringEnvironment(value)"); - const end = addedSource.indexOf("function buildAgentEnvironment(", start); - assert.ok(start >= 0 && end > start); - const hostEnvironment = { XAI_API_KEY: "host-credential-must-not-leak" }; - const resolveEnvironment = new Function( - "process", `${addedSource.slice(start, end)}; return resolveAgentEnvironment;`, - )({ env: hostEnvironment }); - for (const invalid of [undefined, null, [], "invalid", { XAI_API_KEY: 1 }]) { - assert.throws(() => resolveEnvironment(() => invalid), TypeError); - } - const isolated = {}; - assert.equal(resolveEnvironment(() => isolated), isolated); - assert.equal(resolveEnvironment(() => isolated).XAI_API_KEY, undefined); - assert.equal(resolveEnvironment(undefined), hostEnvironment); - assert.match(addedSource, /readEnvCredential\(method\.id, resolveAgentEnvironment\(this\.options\.spawnEnvironment\)\)/); - assert.match(addedSource, /resolveAgentEnvironment\(this\.options\.spawnEnvironment\)\)\.XAI_API_KEY/); -}); - test("the Codex patch enforces isolated instructions, tools, and skills", () => { for (const token of [ "PAPERCLIP_ACPX_ISOLATED_CONTEXT", diff --git a/packages/paperclip-runner/test/acpx-cursor-subagents-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-cursor-subagents-package-contract.test.mjs index 41ae321c280..0c01a3634d3 100644 --- a/packages/paperclip-runner/test/acpx-cursor-subagents-package-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-cursor-subagents-package-contract.test.mjs @@ -4,12 +4,12 @@ import { mkdtemp, rm } from "node:fs/promises"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { pathToFileURL } from "node:url"; import test from "node:test"; +import { acpxInternals } from "./support/acpx-bundle-internals.mjs"; const require = createRequire(import.meta.url); const acpxRoot = process.env.PAPERCLIP_TEST_ACPX_PACKAGE_ROOT ?? dirname(require.resolve("acpx/package.json")); -const { k: AcpClient } = await import(pathToFileURL(join(acpxRoot, "dist/live-checkpoint-BSIrfgVo.js"))); +const { AcpClient } = await acpxInternals(["AcpClient"], acpxRoot); const fixture = String.raw` const readline=require('node:readline'); const send=value=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\n'); diff --git a/packages/paperclip-runner/test/acpx-cursor-usage-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-cursor-usage-package-contract.test.mjs deleted file mode 100644 index 483f533182f..00000000000 --- a/packages/paperclip-runner/test/acpx-cursor-usage-package-contract.test.mjs +++ /dev/null @@ -1,173 +0,0 @@ -import { spawn } from "node:child_process"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; -import { pathToFileURL } from "node:url"; -import test from "node:test"; -import { createCursorNativeUsage } from "../scripts/cursor-native-usage.mjs"; - -const require = createRequire(import.meta.url); -const root = process.env.PAPERCLIP_TEST_ACPX_PACKAGE_ROOT ?? dirname(require.resolve("acpx/package.json")); -const { _: createConversation, y: recordSubmission, n: runPromptTurn, - dt: serialize, ut: parse, tt: assertKeyPolicy, g: clone, E: apply } = - await import(pathToFileURL(join(root, "dist/live-checkpoint-BSIrfgVo.js"))); -const envelope = () => ({ - schema: "paperclip.cursor.native-usage.v1", source: "native_turn_ended", - promptId: "12345678-1234-1234-1234-123456789abc", completeness: "partial", - reasons: ["native_counter_semantics_unverified"], - observations: [{ invocationId: "invocation-1", role: "parent", nativeRun: 1, sequence: 1, - counters: { inputTokens: 12, outputTokens: 3, cacheReadTokens: 0 } }], - limits: { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 }, truncated: false, -}); -const record = conversation => ({ - schema: "acpx.session.v1", acpxRecordId: "record-1", acpSessionId: "session-1", agentCommand: "verified-cursor", - cwd: "/workspace", createdAt: "2026-09-30T00:00:00Z", lastUsedAt: "2026-09-30T00:00:00Z", lastSeq: 0, - ...conversation, -}); -async function prompt(conversation, metadata, options = {}) { - const promptMessageId = options.promptMessageId ?? recordSubmission(conversation, "Fixture prompt"); - const result = await runPromptTurn({ - client: { prompt: async () => ({ stopReason: options.stopReason ?? "end_turn", usage: options.usage, _meta: { paperclipCursorUsage: metadata } }) }, - sessionId: "session-1", prompt: "Fixture prompt", conversation, promptMessageId, - requestId: options.requestId ?? "request-1", - }); - return { promptMessageId, result }; -} - -test("Cursor partial metadata persists separately through clone/apply/disk/reload without accounting", async () => { - const conversation = createConversation(), metadata = envelope(); - const { promptMessageId } = await prompt(conversation, metadata); - const expected = { request_id: "request-1", prompt_message_id: promptMessageId, receipt: metadata }; - assert.deepEqual(conversation.cursor_prompt_usage, expected); - const cloned = clone(conversation); - metadata.observations[0].counters.inputTokens = 999; - assert.equal(cloned.cursor_prompt_usage.receipt.observations[0].counters.inputTokens, 12); - const applied = record(createConversation()); apply(applied, cloned); - const disk = serialize(applied); assertKeyPolicy(disk); - const restored = parse(JSON.parse(JSON.stringify(disk))); - assert.deepEqual(restored.cursor_prompt_usage, cloned.cursor_prompt_usage); - assert.deepEqual(restored.request_token_usage, {}); - assert.deepEqual(restored.cumulative_token_usage, {}); - assert.equal(restored.cumulative_cost, undefined); -}); - -test("malformed, oversized, unknown, and unbound receipts cannot poison successful settlement", async () => { - const malformed = [undefined, null, {}, { ...envelope(), extra: "DROP_ME" }, - { ...envelope(), reasons: ["DROP_ME"] }, { ...envelope(), promptId: "raw-native-id" }, - { ...envelope(), observations: [{ ...envelope().observations[0], counters: { inputTokens: -1 } }] }, - { ...envelope(), observations: Array(65).fill(envelope().observations[0]) }, - { ...envelope(), extra: "X".repeat(16384) }]; - for (const metadata of malformed) { - const conversation = createConversation(); - assert.equal((await prompt(conversation, metadata)).result.stopReason, "end_turn"); - assert.equal(conversation.cursor_prompt_usage, undefined); - } - for (const options of [{ requestId: "invalid request" }, { promptMessageId: "missing-message" }, { stopReason: "refusal" }]) { - const conversation = createConversation(); await prompt(conversation, envelope(), options); - assert.equal(conversation.cursor_prompt_usage, undefined); - } -}); - -test("duplicate request, prompt message, and native prompt receipts do not replace an accepted receipt", async () => { - const conversation = createConversation(); const first = await prompt(conversation, envelope()); - const original = structuredClone(conversation.cursor_prompt_usage); - const updated = { ...envelope(), promptId: "aaaaaaaa-1234-1234-1234-123456789abc" }; - await prompt(conversation, updated); // same request, new message - assert.deepEqual(conversation.cursor_prompt_usage, original); - await prompt(conversation, updated, { requestId: "request-2", promptMessageId: first.promptMessageId }); - assert.deepEqual(conversation.cursor_prompt_usage, original); - await prompt(conversation, envelope(), { requestId: "request-2" }); - assert.deepEqual(conversation.cursor_prompt_usage, original); - await prompt(conversation, updated, { requestId: "request-2", stopReason: "cancelled" }); - assert.equal(conversation.cursor_prompt_usage.request_id, "request-2"); - assert.equal(conversation.cursor_prompt_usage.receipt.promptId, updated.promptId); -}); - -test("reload discards missing/duplicate conversation-message bindings and tampered metadata only", async () => { - const conversation = createConversation(); await prompt(conversation, envelope()); - for (const mutate of [ - disk => { disk.cursor_prompt_usage.prompt_message_id = "missing"; }, - disk => { disk.messages.push(structuredClone(disk.messages[0])); }, - disk => { disk.cursor_prompt_usage.receipt.observations[0].counters.secret = "DROP_ME"; }, - ]) { - const disk = structuredClone(serialize(record(conversation))); mutate(disk); - const restored = parse(disk); - assert.ok(restored); assert.equal(restored.cursor_prompt_usage, undefined); - assert.deepEqual(restored.request_token_usage, {}); - } -}); - - -test("actual runtime manager threads request identity into persisted and reloaded Cursor diagnostics", { timeout: 10000 }, async () => { - const { createAcpRuntime } = await import(pathToFileURL(join(root, "dist/runtime.js"))); - const cwd = await mkdtemp(join(tmpdir(), "acpx-cursor-usage-")); - const records = new Map(); - const sessionStore = { - async load(id) { const disk = records.get(id); return disk ? parse(structuredClone(disk)) : undefined; }, - async save(value) { const disk = serialize(value); assertKeyPolicy(disk); records.set(value.acpxRecordId, structuredClone(disk)); }, - }; - const wire = ` - const readline=require('node:readline'); - const send=value=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\\n'); - readline.createInterface({input:process.stdin}).on('line',line=>{ - const m=JSON.parse(line); - if(m.method==='initialize') send({id:m.id,result:{protocolVersion:1,agentCapabilities:{loadSession:true},authMethods:[]}}); - else if(m.method==='session/new'||m.method==='session/load') send({id:m.id,result:{sessionId:'session-1'}}); - else if(m.method==='session/prompt') send({id:m.id,result:{stopReason:'end_turn',_meta:{paperclipCursorUsage:${JSON.stringify(envelope())}}}}); - }); - `; - const options = { cwd, sessionStore, agentRegistry: { resolve: () => "fixture", list: () => ["cursor"] }, permissionMode: "approve-all", timeoutMs: 3000, - spawnAgent: () => spawn(process.execPath, ["-e", wire], { cwd, env: {}, stdio: ["pipe", "pipe", "pipe"] }), - }; - const runtime = createAcpRuntime(options); let handle; - try { - handle = await runtime.ensureSession({ sessionKey: "cursor-usage", agent: "cursor", mode: "persistent", cwd }); - const turn = runtime.startTurn({ handle, text: "fixture", mode: "prompt", requestId: "exact-runtime-request" }); - const drained = (async () => { for await (const _event of turn.events) {} })(); - assert.equal((await turn.result).status, "completed"); await drained; - const persisted = await sessionStore.load(handle.acpxRecordId); - assert.equal(persisted.lastRequestId, "exact-runtime-request"); - assert.equal(persisted.cursor_prompt_usage.request_id, "exact-runtime-request"); - assert.equal(persisted.messages.filter(m => m?.User?.id === persisted.cursor_prompt_usage.prompt_message_id).length, 1); - assert.deepEqual(persisted.request_token_usage, {}); assert.deepEqual(persisted.cumulative_token_usage, {}); - await runtime.close({ handle, reason: "fixture restart" }); - const reopenedRuntime = createAcpRuntime(options); - const reopened = await reopenedRuntime.ensureSession({ sessionKey: "cursor-usage", agent: "cursor", mode: "persistent", cwd, resumeSessionId: handle.backendSessionId }); - try { - const loaded = await sessionStore.load(reopened.acpxRecordId); - assert.deepEqual(loaded.cursor_prompt_usage, persisted.cursor_prompt_usage); - } finally { await reopenedRuntime.close({ handle: reopened, reason: "fixture complete" }); } - } finally { - if (handle) await runtime.close({ handle, reason: "fixture cleanup" }); - await rm(cwd, { recursive: true, force: true }); - } -}); - - -test("actual native collector near-cap receipt persists with maximum legal request and message IDs", async () => { - const collector = createCursorNativeUsage(envelope().promptId); - for (let i = 0; i < 64; i++) collector.beginParent()?.observe({ message: { case: "turnEnded", value: Object.fromEntries( - ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"].map(k => [k, Number.MAX_SAFE_INTEGER])) } }); - const receipt = collector.finish(); - assert.equal(receipt.truncated, true); assert.ok(JSON.stringify(receipt).length > 15000); - const conversation = createConversation(); recordSubmission(conversation, "fixture"); - const messageId = "m".repeat(240); conversation.messages[0].User.id = messageId; - await prompt(conversation, receipt, { requestId: "r".repeat(240), promptMessageId: messageId }); - assert.deepEqual(conversation.cursor_prompt_usage.receipt, receipt); - assert.ok(Buffer.byteLength(JSON.stringify(conversation.cursor_prompt_usage)) <= 16384); - const restored = parse(serialize(record(conversation))); - assert.deepEqual(restored.cursor_prompt_usage.receipt.observations, receipt.observations); -}); - - -test("standard prompt token accounting stays independent of native diagnostic counters", async () => { - const conversation = createConversation(); const native = envelope(); - native.observations[0].counters.inputTokens = 999999; - const { promptMessageId } = await prompt(conversation, native, { usage: { inputTokens: 12, outputTokens: 3 } }); - assert.equal(conversation.request_token_usage[promptMessageId].input_tokens, 12); - assert.equal(conversation.cumulative_token_usage.input_tokens, 12); - assert.equal(conversation.cursor_prompt_usage.receipt.observations[0].counters.inputTokens, 999999); - assert.equal(conversation.cumulative_cost, undefined); -}); diff --git a/packages/paperclip-runner/test/acpx-model-selection-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-model-selection-package-contract.test.mjs index ca099899807..0c1c637c9ad 100644 --- a/packages/paperclip-runner/test/acpx-model-selection-package-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-model-selection-package-contract.test.mjs @@ -6,12 +6,16 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; import test from "node:test"; +import { acpxInternals } from "./support/acpx-bundle-internals.mjs"; const require = createRequire(import.meta.url); const root = process.env.PAPERCLIP_TEST_ACPX_PACKAGE_ROOT ?? dirname(require.resolve("acpx/package.json")); const { createAcpRuntime } = await import(pathToFileURL(join(root, "dist/runtime.js"))); -const { dt: serialize, ut: parse } = await import(pathToFileURL(join(root, "dist/live-checkpoint-BSIrfgVo.js"))); +const { serializeSessionRecordForDisk: serialize, parseSessionRecord: parse } = await acpxInternals(["serializeSessionRecordForDisk", "parseSessionRecord"], root); const selectedModel = "custom/model"; +// Paperclip offers only models the provider advertises. Cursor-style bracketed +// IDs are exact and must never be rewritten. +const advertisedModel = "custom/model[variant=x]"; // Exercise the installed ACPX manager and ACP wire, including saved model replay. // Only the provider process is a fixture; it never connects to a model service. @@ -72,7 +76,7 @@ async function fixture(t, agent, behavior = {}) { async open() { const runtime = createAcpRuntime(options), owner = { runtime, handle: null }; runtimes.push(owner); - owner.handle = await runtime.ensureSession({ sessionKey: "model-selection", agent, mode: "persistent", cwd, sessionOptions: { model: selectedModel, ...behavior.sessionOptions } }); + owner.handle = await runtime.ensureSession({ sessionKey: "model-selection", agent, mode: "persistent", cwd, sessionOptions: { model: behavior.model ?? selectedModel, ...behavior.sessionOptions } }); return owner; }, async prompt(owner) { @@ -87,11 +91,11 @@ async function fixture(t, agent, behavior = {}) { }; } -for (const agent of ["claude", "codex", "grok", "cursor", "copilot", "pi"]) { - test(`${agent}: unlisted model reaches the provider unchanged on startup and reconnect`, { timeout: 10_000 }, async t => { - const f = await fixture(t, agent); +for (const agent of ["codex", "cursor", "copilot", "pi"]) { + test(`${agent}: an advertised exact model reaches the provider unchanged on startup and reconnect`, { timeout: 10_000 }, async t => { + const f = await fixture(t, agent, { model: advertisedModel }); const first = await f.open(); - assert.deepEqual(f.selected(), [selectedModel]); + assert.deepEqual(f.selected(), [advertisedModel]); assert.equal((await f.prompt(first)).status, "completed"); await f.close(first); const beforeReconnect = f.selected().length; @@ -99,51 +103,55 @@ for (const agent of ["claude", "codex", "grok", "cursor", "copilot", "pi"]) { assert.equal((await f.prompt(second)).status, "completed"); assert.ok(f.requests.some(r => r.method === "session/load")); assert.ok(f.selected().length > beforeReconnect, "fresh connection replays the selected model"); - assert.ok(f.selected().every(model => model === selectedModel), "never substitute an advertised variant"); + assert.ok(f.selected().every(model => model === advertisedModel), "never substitute another advertised variant"); }); } -for (const response of ["reject", "mismatch", "missing"]) { - const error = response === "reject" ? /fixture provider rejected model/ : response === "missing" ? /did not advertise model support/ : /effective model mismatch/; - test(`provider ${response} on startup prevents prompting without fallback`, async t => { - const f = await fixture(t, "cursor", { response }); - await assert.rejects(f.open(), error); - assert.deepEqual(f.selected(), response === "missing" ? [] : [selectedModel]); - assert.equal(f.requests.some(r => r.method === "session/prompt"), false); - }); - test(`provider ${response} on reconnect prevents prompting without fallback`, async t => { - const f = await fixture(t, "cursor"); - const first = await f.open(); - assert.equal((await f.prompt(first)).status, "completed"); - await f.close(first); - const promptCount = f.requests.filter(r => r.method === "session/prompt").length; - f.setResponse(response); - try { - const second = await f.open(); - const result = await f.prompt(second); - assert.equal(result.status, "failed"); - assert.match(JSON.stringify(result), error); - } catch (failure) { assert.match(failure.message, error); } - assert.ok(f.selected().every(model => model === selectedModel)); - assert.equal(f.requests.filter(r => r.method === "session/prompt").length, promptCount); - }); -} +test("an unadvertised model is rejected before it reaches the provider", async t => { + const f = await fixture(t, "cursor"); + await assert.rejects(f.open(), /did not advertise that model/); + assert.deepEqual(f.selected(), []); + assert.equal(f.requests.some(r => r.method === "session/prompt"), false); +}); +test("Claude forwards a model outside its advertised list for the adapter to accept or reject", async t => { + const f = await fixture(t, "claude", { agentCommand: "/paperclip-verified/claude-agent-acp" }); + const owner = await f.open(); + assert.equal((await f.prompt(owner)).status, "completed"); + assert.ok(f.selected().every(model => model === selectedModel)); +}); -test("an already selected unlisted model needs no replacement", async t => { - const f = await fixture(t, "cursor", { currentModel: selectedModel }); +test("provider rejection on startup prevents prompting without fallback", async t => { + const f = await fixture(t, "cursor", { model: advertisedModel, response: "reject" }); + await assert.rejects(f.open(), /fixture provider rejected model/); + assert.deepEqual(f.selected(), [advertisedModel]); + assert.equal(f.requests.some(r => r.method === "session/prompt"), false); +}); + +test("provider rejection on reconnect prevents prompting without fallback", async t => { + const f = await fixture(t, "cursor", { model: advertisedModel }); + const first = await f.open(); + assert.equal((await f.prompt(first)).status, "completed"); + await f.close(first); + const promptCount = f.requests.filter(r => r.method === "session/prompt").length; + f.setResponse("reject"); + try { + const second = await f.open(); + const result = await f.prompt(second); + assert.equal(result.status, "failed"); + assert.match(JSON.stringify(result), /fixture provider rejected model/); + } catch (failure) { assert.match(failure.message, /fixture provider rejected model/); } + assert.ok(f.selected().every(model => model === advertisedModel)); + assert.equal(f.requests.filter(r => r.method === "session/prompt").length, promptCount); +}); + +test("an already selected model needs no replacement on startup", async t => { + const f = await fixture(t, "cursor", { model: advertisedModel, currentModel: advertisedModel }); const owner = await f.open(); assert.equal((await f.prompt(owner)).status, "completed"); assert.deepEqual(f.selected(), []); }); -test("Cursor command detection never expands a model into an advertised variant", async t => { - const f = await fixture(t, "cursor", { agentCommand: "cursor-agent acp" }); - const owner = await f.open(); - assert.equal((await f.prompt(owner)).status, "completed"); - assert.deepEqual(f.selected(), [selectedModel]); -}); - for (const includeUserSettings of [false, true]) for (const resume of [false, true]) test(`Claude ${resume ? "resume" : "load"} preserves native session options (user settings ${includeUserSettings})`, { timeout: 10_000 }, async t => { const prior = process.env.ACPX_CLAUDE_INCLUDE_USER_SETTINGS; if (includeUserSettings) process.env.ACPX_CLAUDE_INCLUDE_USER_SETTINGS = "1"; diff --git a/packages/paperclip-runner/test/acpx-pi-receipt-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-pi-receipt-package-contract.test.mjs deleted file mode 100644 index 59d13f84fca..00000000000 --- a/packages/paperclip-runner/test/acpx-pi-receipt-package-contract.test.mjs +++ /dev/null @@ -1,90 +0,0 @@ -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; -import { pathToFileURL } from "node:url"; -import test from "node:test"; - -const require = createRequire(import.meta.url); -const root = dirname(require.resolve("acpx/package.json")); -const { - _: createConversation, y: recordSubmission, n: runPromptTurn, - dt: serialize, ut: parse, tt: assertKeyPolicy, -} = await import(pathToFileURL(join(root, "dist/live-checkpoint-BSIrfgVo.js"))); - -async function recordReceipt(metadata, usage = { inputTokens: 12, outputTokens: 3 }) { - const conversation = createConversation(); - const promptMessageId = recordSubmission(conversation, "Fixture prompt"); - await runPromptTurn({ - client: { prompt: async () => ({ stopReason: "end_turn", usage: { ...usage, _meta: { paperclipPi: metadata, secret: "DROP_ME" } } }) }, - sessionId: "session-1", prompt: "Fixture prompt", conversation, promptMessageId, - }); - return { conversation, promptMessageId }; -} - -test("Pi prompt receipts preserve exact per-request provenance and USD cost including zero", async () => { - for (const provenance of ["assistant_message_receipts", "assistant_message_and_compaction_receipts"]) for (const costUsd of [0, 0.0025]) { - const { conversation, promptMessageId } = await recordReceipt({ provenance, costUsd, extra: "DROP_ME" }); - assert.deepEqual(conversation.request_token_usage[promptMessageId].paperclip_pi, { provenance, cost_usd: costUsd }); - assert.equal(conversation.cumulative_token_usage.paperclip_pi, undefined); - assert.equal(conversation.cumulative_cost, undefined); - assert.equal(JSON.stringify(conversation).includes("DROP_ME"), false); - } -}); - -test("missing cost stays missing and untrusted provenance or invalid costs never become receipts", async () => { - const missing = await recordReceipt({ provenance: "assistant_message_receipts" }, {}); - assert.deepEqual(missing.conversation.request_token_usage[missing.promptMessageId].paperclip_pi, { provenance: "assistant_message_receipts" }); - for (const metadata of [{ provenance: "estimated", costUsd: 1 }, { provenance: "compaction_receipts", costUsd: 1 }, { provenance: "assistant_message_and_compaction_receipts", costUsd: -1 }, { provenance: "assistant_message_receipts", costUsd: -1 }, { provenance: "assistant_message_receipts", costUsd: Infinity }, { provenance: "assistant_message_receipts", costUsd: "0.01" }]) { - const { conversation, promptMessageId } = await recordReceipt(metadata); - assert.equal(conversation.request_token_usage[promptMessageId].paperclip_pi, undefined); - } -}); - -test("terminal failures preserve billable Pi receipts for the failing request before reporting failure", async () => { - for (const provenance of ["assistant_message_receipts", "assistant_message_and_compaction_receipts"]) { - const { conversation, promptMessageId: previousId } = await recordReceipt({ provenance, costUsd: 0.001 }); - const promptMessageId = recordSubmission(conversation, "Fail after paid work"); - let failureReceipt; - await assert.rejects(runPromptTurn({ - client: { prompt: async () => ({ - stopReason: "end_turn", - usage: { inputTokens: 21, outputTokens: 8, _meta: { paperclipPi: { provenance, costUsd: 0.003 }, secret: "DROP_ME" } }, - _meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "error", category: "limit" } } } }, - }) }, - sessionId: "session-1", prompt: "Fail after paid work", conversation, promptMessageId, - onTerminalSessionFailure() { failureReceipt = structuredClone(conversation.request_token_usage[promptMessageId]); }, - }), /ACP agent reported a terminal limit failure/); - assert.equal(failureReceipt?.input_tokens, 21); - assert.equal(failureReceipt?.output_tokens, 8); - assert.deepEqual(failureReceipt?.paperclip_pi, { provenance, cost_usd: 0.003 }); - assert.equal(conversation.request_token_usage[previousId].paperclip_pi.cost_usd, 0.001); - const disk = serialize({ - schema: "acpx.session.v1", acpxRecordId: "record-1", acpSessionId: "session-1", agentCommand: "verified-pi", - cwd: "/workspace", createdAt: "2026-09-28T00:00:00Z", lastUsedAt: "2026-09-28T00:00:00Z", lastSeq: 0, - ...conversation, - }); - assertKeyPolicy(disk); - const restored = parse(JSON.parse(JSON.stringify(disk))); - assert.ok(restored); - assert.deepEqual(restored.request_token_usage[promptMessageId].paperclip_pi, { provenance, cost_usd: 0.003 }); - assert.equal(restored.request_token_usage[promptMessageId].input_tokens, 21); - assert.equal(restored.request_token_usage[promptMessageId].output_tokens, 8); - assert.equal(JSON.stringify(restored).includes("DROP_ME"), false); - } -}); - -for (const provenance of ["assistant_message_receipts", "assistant_message_and_compaction_receipts"]) test(`${provenance} survives canonical disk serialization/reload with a closed metadata shape`, async () => { - const { conversation, promptMessageId } = await recordReceipt({ provenance, costUsd: 0.01 }); - const record = { - schema: "acpx.session.v1", acpxRecordId: "record-1", acpSessionId: "session-1", agentCommand: "verified-pi", - cwd: "/workspace", createdAt: "2026-09-28T00:00:00Z", lastUsedAt: "2026-09-28T00:00:00Z", lastSeq: 0, - ...conversation, - }; - const disk = serialize(record); - assertKeyPolicy(disk); - disk.request_token_usage[promptMessageId].paperclip_pi.extra = "DROP_ME"; - const restored = parse(JSON.parse(JSON.stringify(disk))); - assert.ok(restored); - assert.deepEqual(restored.request_token_usage[promptMessageId].paperclip_pi, { provenance, cost_usd: 0.01 }); - assert.equal(JSON.stringify(restored).includes("DROP_ME"), false); -}); diff --git a/packages/paperclip-runner/test/acpx-response-delivery-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-response-delivery-package-contract.test.mjs deleted file mode 100644 index 7d972be5674..00000000000 --- a/packages/paperclip-runner/test/acpx-response-delivery-package-contract.test.mjs +++ /dev/null @@ -1,100 +0,0 @@ -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { mkdtemp, rm } from "node:fs/promises"; -import { createRequire } from "node:module"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { pathToFileURL } from "node:url"; -import test from "node:test"; - -const require = createRequire(import.meta.url); -const acpxRoot = process.env.PAPERCLIP_TEST_ACPX_PACKAGE_ROOT ?? dirname(require.resolve("acpx/package.json")); -const { k: AcpClient } = await import(pathToFileURL(join(acpxRoot, "dist/live-checkpoint-BSIrfgVo.js"))); -const deferred = () => Promise.withResolvers(); -const nextTick = () => new Promise(resolve => setImmediate(resolve)); - -function tapped(write) { - const client = new AcpClient({ agentCommand: "fixture", cwd: process.cwd(), permissionMode: "approve-all" }); - client.activePrompt = { sessionId: "session", elicitationController: new AbortController() }; - const stream = client.createTappedStream({ readable: new ReadableStream({ start() {} }), writable: new WritableStream({ write }) }); - return { client, stream, controller: client.activePrompt.elicitationController }; -} - -test("receipt stays pending until the exact JSON-RPC result reaches the underlying writer", async () => { - const gate = deferred(); let receipt; let delivered = false; - const { stream } = tapped(() => gate.promise); - const result = await stream.withResponseDelivery(0, new AbortController().signal, async promise => { - receipt = promise; void receipt.then(() => { delivered = true; }); return { selected: "yes" }; - }); - const writer = stream.writable.getWriter(); - const written = writer.write({ jsonrpc: "2.0", id: 0, result }); - await nextTick(); assert.equal(delivered, false); - gate.resolve(); await written; await receipt; assert.equal(delivered, true); - stream.abortResponseDeliveries(); writer.releaseLock(); -}); - -for (const failure of ["write", "cancel", "disconnect", "changed", "error"]) { - test(`receipt rejects ${failure} after callback resolution instead of falsely acknowledging delivery`, async () => { - const gate = deferred(); let receipt; - const { stream, controller } = tapped(() => gate.promise); - const result = await stream.withResponseDelivery("request", new AbortController().signal, async promise => { receipt = promise; return { selected: "yes" }; }); - const rejected = assert.rejects(receipt); - const writer = stream.writable.getWriter(); - const wire = failure === "error" ? { error: { code: -32000, message: "cancelled" } } - : { result: failure === "changed" ? { selected: "no" } : result }; - const written = writer.write({ jsonrpc: "2.0", id: "request", ...wire }); - if (failure === "write") { const writeFailed = assert.rejects(written); gate.reject(new Error("fixture write failure")); await writeFailed; } - else { - if (failure === "cancel") controller.abort(); - if (failure === "disconnect") stream.abortResponseDeliveries(); - gate.resolve(); await written; - } - await rejected; stream.abortResponseDeliveries(); writer.releaseLock(); - }); -} - -test("duplicate unresolved IDs poison receipts; an old response cannot acknowledge a new request", async () => { - let receipt; const pending = deferred(); - const { stream } = tapped(() => {}); - const first = stream.withResponseDelivery(0, new AbortController().signal, async promise => { receipt = promise; await pending.promise; return {}; }); - const rejected = assert.rejects(receipt); - await assert.rejects(stream.withResponseDelivery(0, new AbortController().signal, async () => ({})), /duplicated/); - await rejected; const expired = assert.rejects(first, /outlived/); pending.resolve(); await expired; -}); - -const fixture = String.raw` -const readline = require('node:readline'); -const send = value => process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\n'); -let promptId; -readline.createInterface({input:process.stdin}).on('line', line => { - const message=JSON.parse(line); - if(message.method==='initialize') send({id:message.id,result:{protocolVersion:1,agentCapabilities:{},authMethods:[]}}); - else if(message.method==='session/new') send({id:message.id,result:{sessionId:'session'}}); - else if(message.method==='session/prompt') { - promptId=message.id; const mode=message.params.prompt[0].text; - if(mode==='permission') send({id:0,method:'session/request_permission',params:{sessionId:'session',toolCall:{toolCallId:'tool',title:'Edit',kind:'edit',status:'pending'},options:[{optionId:'yes',name:'Allow',kind:'allow_once'},{optionId:'no',name:'Deny',kind:'reject_once'}]}}); - else if(mode==='elicitation') send({id:0,method:'elicitation/create',params:{sessionId:'session',mode:'form',message:'Choose',requestedSchema:{type:'object',properties:{name:{type:'string'}}}}}); - else send({id:0,method:'fixture/question',params:{sessionId:'session'}}); - } else if(message.id===0 && !message.method) { - send({method:'session/update',params:{sessionId:'session',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:JSON.stringify(message)}}}}); - send({id:promptId,result:{stopReason:'end_turn'}}); - } -}); -`; -for (const mode of ["extension", "permission", "elicitation"]) { - test(`real ${mode} request supplies a stream-owned receipt without callback deadlock`, { timeout: 10000 }, async () => { - const cwd = await mkdtemp(join(tmpdir(), "acpx-response-delivery-")); - let delivery; let seen = 0; - const remember = context => { seen++; assert.ok(context.responseDelivery instanceof Promise); delivery = context.responseDelivery; }; - const client = new AcpClient({ agentCommand: "fixture", cwd, authPolicy: "skip", permissionMode: "approve-all", fs: false, terminal: false, elicitationModes: ["form"], extensionMethods: ["fixture/question"], - spawnAgent: () => spawn(process.execPath, ["-e", fixture], { cwd, stdio: ["pipe", "pipe", "pipe"] }), - onExtensionRequest: async (_method, _params, context) => { remember(context); return { status: "accepted" }; }, - onPermissionRequest: async (_params, context) => { remember(context); return { outcome: "allow_once" }; }, - }); - try { - await client.start(); await client.createSession(); - const result = await client.prompt("session", mode, undefined, async (_params, context) => { remember(context); return { action: "accept", content: { name: "Paperclip" } }; }); - assert.equal(result.stopReason, "end_turn"); assert.equal(seen, 1); await delivery; - } finally { await client.close(); await rm(cwd, { recursive: true, force: true }); } - }); -} diff --git a/packages/paperclip-runner/test/acpx-rich-extensions-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-rich-extensions-package-contract.test.mjs index 50bfcd016a7..eecc56c0f3b 100644 --- a/packages/paperclip-runner/test/acpx-rich-extensions-package-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-rich-extensions-package-contract.test.mjs @@ -4,12 +4,12 @@ import { mkdtemp, rm, readFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { pathToFileURL } from "node:url"; import test from "node:test"; +import { acpxInternals, acpxChunkSource } from "./support/acpx-bundle-internals.mjs"; const require = createRequire(import.meta.url); const acpxRoot = dirname(require.resolve("acpx/package.json")); -const { k: AcpClient } = await import(pathToFileURL(join(acpxRoot, "dist/live-checkpoint-BSIrfgVo.js"))); +const { AcpClient } = await acpxInternals(["AcpClient"], acpxRoot); const fixture = String.raw` const readline = require('node:readline'); const send = (value) => process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\n'); @@ -75,7 +75,8 @@ test("initialize retains mandatory capabilities while merging provider metadata" assert.equal(actual.terminal, false); assert.deepEqual(actual.elicitation, { form: {} }); assert.deepEqual(actual._meta["github.com/copilot"], { events: ["session.idle"] }); - assert.deepEqual(actual._meta.jetbrains.air.capabilities, ["sessionFailure"]); + // ACPX adds no metadata of its own; Runner advertises typed AIR failures itself. + assert.deepEqual(actual._meta.jetbrains, { removed: true }); }); }); @@ -138,7 +139,7 @@ test("runtime forwards extension hooks through ephemeral client options", async for (const field of ["clientCapabilities", "extensionMethods", "onExtensionRequest", "onExtensionNotification"]) { assert.ok(runtime.includes(`${field}: this.options.${field}`)); } - const implementation = await readFile(join(acpxRoot, "dist/live-checkpoint-BSIrfgVo.js"), "utf8"); + const implementation = await acpxChunkSource("serializeSessionRecordForDisk", acpxRoot); const serialization = implementation.slice(implementation.indexOf("function serializeSessionRecordForDisk"), implementation.indexOf("function serializeSessionRecordForDisk") + 6000); assert.doesNotMatch(serialization, /onExtensionRequest|onExtensionNotification|clientCapabilities|extensionMethods/); }); diff --git a/packages/paperclip-runner/test/acpx-session-carry-forward-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-session-carry-forward-package-contract.test.mjs new file mode 100644 index 00000000000..403085a2708 --- /dev/null +++ b/packages/paperclip-runner/test/acpx-session-carry-forward-package-contract.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +const root = process.env.PAPERCLIP_TEST_ACPX_PACKAGE_ROOT ?? dirname(require.resolve("acpx/package.json")); +const { createAcpRuntime, createAgentRegistry, createRuntimeStore } = await import(pathToFileURL(join(root, "dist/runtime.js"))); + +// This record was written by the previous release: ACPX 0.13.1 with Paperclip's +// 0.13.1 patch, after one completed turn. Only absolute paths were replaced. +const savedRecord = JSON.parse(await readFile(new URL("./fixtures/acpx-0.13.1-session-record.json", import.meta.url), "utf8")); +const peer = String.raw` +const fs = require('node:fs'); +const send = x => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...x }) + '\n'); +require('node:readline').createInterface({ input: process.stdin }).on('line', line => { + const m = JSON.parse(line); + if (m.method) fs.appendFileSync(process.env.WIRE, JSON.stringify({ method: m.method, sessionId: m.params?.sessionId }) + '\n'); + if (m.method === 'initialize') send({ id: m.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: true }, authMethods: [] } }); + else if (m.method === 'session/new') send({ id: m.id, result: { sessionId: 'fresh-session' } }); + else if (m.method === 'session/load') send({ id: m.id, result: {} }); + else if (m.method === 'session/prompt') { + send({ method: 'session/update', params: { sessionId: m.params.sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'CARRIED_OK' } } } }); + send({ id: m.id, result: { stopReason: 'end_turn' } }); + } else if (m.id !== undefined && m.method) send({ id: m.id, result: {} }); +});`; + +test("a session saved by ACPX 0.13.1 continues under the installed ACPX", { timeout: 15_000 }, async t => { + const dir = await mkdtemp(join(tmpdir(), "acpx-carry-forward-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const cwd = join(dir, "workspace"); + const stateDir = join(dir, "state"); + const wire = join(dir, "wire.ndjson"); + await mkdir(cwd, { recursive: true }); + await mkdir(join(stateDir, "sessions"), { recursive: true }); + await writeFile(join(stateDir, "sessions", "carried-key.json"), JSON.stringify({ + ...savedRecord, + cwd, + event_log: { ...savedRecord.event_log, active_path: join(stateDir, "sessions", "carried-key.stream.ndjson") }, + })); + const children = []; + const runtime = createAcpRuntime({ + cwd, sessionStore: createRuntimeStore({ stateDir }), permissionMode: "deny-all", + agentRegistry: createAgentRegistry({ overrides: { fixture: "fixture-agent" } }), + spawnAgent: () => { + const child = spawn(process.execPath, ["-e", peer], { cwd, env: { WIRE: wire }, stdio: ["pipe", "pipe", "pipe"] }); + children.push(child); + return child; + }, + }); + t.after(() => { for (const child of children) if (child.exitCode === null) child.kill("SIGKILL"); }); + + const handle = await runtime.ensureSession({ sessionKey: "carried-key", agent: "fixture", mode: "persistent", cwd }); + assert.equal(handle.acpxRecordId, "carried-key"); + assert.equal(handle.backendSessionId, "carried-session"); + const turn = runtime.startTurn({ handle, text: "second turn", mode: "prompt", requestId: "turn-2", timeoutMs: 5_000 }); + let text = ""; + for await (const event of turn.events) if (event.type === "text_delta" && event.stream !== "thought") text += event.text; + assert.equal((await turn.result).status, "completed"); + assert.equal(text, "CARRIED_OK"); + await runtime.close({ handle, reason: "carry-forward verified" }); + + const methods = (await readFile(wire, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + // The provider conversation is loaded, never replaced by a new session. + assert.ok(methods.some(entry => entry.method === "session/load" && entry.sessionId === "carried-session")); + assert.equal(methods.some(entry => entry.method === "session/new"), false); + assert.ok(methods.some(entry => entry.method === "session/prompt" && entry.sessionId === "carried-session")); + const resaved = JSON.parse(await readFile(join(stateDir, "sessions", "carried-key.json"), "utf8")); + // The earlier turn stays in the conversation, followed by the new one. + assert.deepEqual(resaved.messages.flatMap(message => message.User ? [message.User.content[0].Text] : []), ["first turn", "second turn"]); +}); diff --git a/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs index 7de94511896..cb3097483c0 100644 --- a/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs @@ -162,7 +162,7 @@ test("public Pi descriptors preserve effective thinking levels without invalidat const validateDescriptor = new Ajv2020({ allErrors: true, strict: true, strictRequired: false }).compile(descriptorSchema); const historical = { provider: "acpx", driver: "acpx_runtime", model: "model", executionKind: "local_process", - providerVersion: "0.13.1", agent: "pi", requestedModel: "model", acpProtocolVersion: 1, + providerVersion: "0.22.0", agent: "pi", requestedModel: "model", acpProtocolVersion: 1, agentServerPackage: "pi-acp", agentServerVersion: "0.0.33", acpxRecordId: null, agentProcessId: null, }; assert.equal(validateDescriptor(historical), true, JSON.stringify(validateDescriptor.errors)); diff --git a/packages/paperclip-runner/test/fixtures/acpx-0.13.1-session-record.json b/packages/paperclip-runner/test/fixtures/acpx-0.13.1-session-record.json new file mode 100644 index 00000000000..1d0b708b0d3 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/acpx-0.13.1-session-record.json @@ -0,0 +1,63 @@ +{ + "schema": "acpx.session.v1", + "acpx_record_id": "carried-key", + "acp_session_id": "carried-session", + "agent_command": "fixture-agent", + "cwd": "/fixture/workspace", + "name": "carried-key", + "created_at": "2026-11-11T08:33:06.784Z", + "last_used_at": "2026-11-11T08:33:07.796Z", + "last_seq": 0, + "last_request_id": "turn-1", + "event_log": { + "active_path": "/fixture/state/sessions/carried-key.stream.ndjson", + "segment_count": 5, + "max_segment_bytes": 67108864, + "max_segments": 5, + "last_write_error": null + }, + "closed": true, + "closed_at": "2026-11-11T08:33:07.804Z", + "pid": 1, + "agent_started_at": "2026-11-11T08:33:06.730Z", + "last_prompt_at": "2026-11-11T08:33:06.788Z", + "protocol_version": 1, + "agent_capabilities": { + "loadSession": true + }, + "agent_goal_capability": { + "version": 1, + "control_method": "_session/goal", + "actions": [ + "set", + "clear" + ] + }, + "title": null, + "messages": [ + { + "User": { + "id": "9643e526-b0fc-4126-a6fb-3517837da168", + "content": [ + { + "Text": "first turn" + } + ] + } + }, + { + "Agent": { + "content": [ + { + "Text": "TURN_OK" + } + ], + "tool_results": {} + } + } + ], + "updated_at": "2026-11-11T08:33:06.791Z", + "cumulative_token_usage": {}, + "request_token_usage": {}, + "acpx": {} +} diff --git a/packages/paperclip-runner/test/support/acpx-bundle-internals.d.mts b/packages/paperclip-runner/test/support/acpx-bundle-internals.d.mts new file mode 100644 index 00000000000..98bfbc0dc3c --- /dev/null +++ b/packages/paperclip-runner/test/support/acpx-bundle-internals.d.mts @@ -0,0 +1,5 @@ +export function acpxPackageRoot(): string; +export function acpxBundleChunks(names: readonly string[], root?: string): Promise>; +// Internals are untyped bundle exports; callers narrow what they use. +export function acpxInternals(names: readonly string[], root?: string): Promise>; +export function acpxChunkSource(name: string, root?: string): Promise; diff --git a/packages/paperclip-runner/test/support/acpx-bundle-internals.mjs b/packages/paperclip-runner/test/support/acpx-bundle-internals.mjs new file mode 100644 index 00000000000..9d5bf33f162 --- /dev/null +++ b/packages/paperclip-runner/test/support/acpx-bundle-internals.mjs @@ -0,0 +1,51 @@ +// Package-contract tests exercise patched ACPX internals that the package does +// not export publicly. Bundled chunks re-export them under minified aliases +// that change on every release, so resolve each one by its source name from +// the chunk's export map instead of pinning a chunk file or alias letter. +import { readdir, readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const require = createRequire(import.meta.url); + +export function acpxPackageRoot() { + return process.env.PAPERCLIP_TEST_ACPX_PACKAGE_ROOT ?? dirname(require.resolve("acpx/package.json")); +} + +function exportMap(source) { + const statement = source.match(/^export \{([^}]*)\};\s*(?:\/\/#.*)?\s*$/m); + if (!statement) return new Map(); + return new Map(statement[1].split(",").map((entry) => entry.trim()).filter(Boolean).map((entry) => { + const [local, alias = local] = entry.split(/\s+as\s+/); + return [local, alias]; + })); +} + +/** Absolute paths of the chunks that define and export each requested name. */ +export async function acpxBundleChunks(names, root = acpxPackageRoot()) { + const dist = join(root, "dist"); + const located = new Map(); + for (const file of (await readdir(dist)).filter((name) => name.endsWith(".js")).sort()) { + const path = join(dist, file); + const exported = exportMap(await readFile(path, "utf8")); + for (const name of names) if (!located.has(name) && exported.has(name)) located.set(name, { path, alias: exported.get(name) }); + } + const missing = names.filter((name) => !located.has(name)); + if (missing.length > 0) throw new Error(`ACPX bundle does not export ${missing.join(", ")}`); + return located; +} + +/** Imports named ACPX internals, e.g. `const { AcpClient } = await acpxInternals(["AcpClient"])`. */ +export async function acpxInternals(names, root = acpxPackageRoot()) { + const located = await acpxBundleChunks(names, root); + const result = {}; + for (const [name, { path, alias }] of located) result[name] = (await import(pathToFileURL(path).href))[alias]; + return result; +} + +/** Source text of the chunk that defines `name`, for structural assertions. */ +export async function acpxChunkSource(name, root = acpxPackageRoot()) { + const located = await acpxBundleChunks([name], root); + return await readFile(located.get(name).path, "utf8"); +} diff --git a/patches/acpx@0.12.0.patch b/patches/acpx@0.12.0.patch deleted file mode 100644 index fa8410d89b5..00000000000 --- a/patches/acpx@0.12.0.patch +++ /dev/null @@ -1,248 +0,0 @@ -diff --git a/dist/live-checkpoint-ClPCSdrW.js b/dist/live-checkpoint-ClPCSdrW.js ---- a/dist/live-checkpoint-ClPCSdrW.js -+++ b/dist/live-checkpoint-ClPCSdrW.js -@@ -1532,7 +1532,7 @@ - "RedactedThinking", - "ToolUse" - ]); --const MAP_OBJECT_PATHS = /* @__PURE__ */ new Set(["request_token_usage", "messages.Agent.tool_results"]); -+const MAP_OBJECT_PATHS = /* @__PURE__ */ new Set(["request_token_usage", "messages.Agent.tool_results", "acpx.session_options.env"]); - const OPAQUE_VALUE_PATHS = /* @__PURE__ */ new Set([ - "agent_capabilities", - "messages.Agent.content.ToolUse.input", -@@ -2557,7 +2557,7 @@ - escaping: false, - hasPart: true - }; -- if (state.ch === "\\" && state.quote !== "'") return { -+ if (process.platform !== "win32" && state.ch === "\\" && state.quote !== "'") return { - current: state.current, - quote: state.quote, - escaping: true, -@@ -2912,8 +2912,8 @@ - } - return protectedKeys; - } --function buildAgentEnvironment(authCredentials, sessionEnv) { -- const env = { ...process.env }; -+function buildAgentEnvironment(authCredentials, sessionEnv, inheritProcessEnv = true) { -+ const env = inheritProcessEnv ? { ...process.env } : {}; - const protectedAuthEnvKeys = promotePrefixedAuthEnvironment(env); - if (authCredentials) for (const [methodId, credential] of Object.entries(authCredentials)) { - addAuthCredentialEnvKeys(protectedAuthEnvKeys, methodId, credential); -@@ -2955,10 +2955,10 @@ - const configCredentials = authCredentials ?? {}; - return configCredentials[methodId] ?? configCredentials[toEnvToken(methodId)]; - } --function buildAgentSpawnOptions(cwd, authCredentials, sessionEnv) { -+function buildAgentSpawnOptions(cwd, authCredentials, sessionEnv, inheritProcessEnv) { - return { - cwd, -- env: buildAgentEnvironment(authCredentials, sessionEnv), -+ env: buildAgentEnvironment(authCredentials, sessionEnv, inheritProcessEnv), - stdio: [ - "pipe", - "pipe", -@@ -3724,10 +3724,13 @@ - }, - terminal: params.terminal - }; -- if (!params.devinAcp) return baseCapabilities; -+ const typedSessionFailureMeta = { -+ jetbrains: { air: { version: 1, capabilities: ["sessionFailure"] } } -+ }; -+ if (!params.devinAcp) return { ...baseCapabilities, _meta: typedSessionFailureMeta }; - return { - ...baseCapabilities, -- _meta: DEVIN_COMPATIBILITY_CLIENT_CAPABILITIES_META -+ _meta: { ...typedSessionFailureMeta, ...DEVIN_COMPATIBILITY_CLIENT_CAPABILITIES_META } - }; - } - function isDevinRequestDiagnosticsMethod(method) { -@@ -3957,9 +3960,26 @@ - this.lastAgentExit = void 0; - this.lastKnownPid = child.pid ?? void 0; - this.attachAgentLifecycleObservers(child); -+ if (this.options.onAgentSpawn) { -+ try { -+ if (typeof child.pid !== "number" || child.pid <= 0) { -+ throw new Error("ACPX agent spawn did not expose a valid process id."); -+ } -+ await this.options.onAgentSpawn({ pid: child.pid, startedAt: this.agentStartedAt }); -+ } catch (error) { -+ try { -+ child.kill("SIGKILL"); -+ } catch {} -+ throw error; -+ } -+ } - const startupStderr = []; - child.stderr.on("data", (chunk) => { - this.captureStartupStderr(startupStderr, chunk); -+ if (this.options.onAgentStderr) { -+ this.options.onAgentStderr(chunk.toString()); -+ return; -+ } - if (!this.options.verbose) return; - process.stderr.write(chunk); - }); -@@ -3994,7 +4014,7 @@ - geminiAcp: isGeminiAcpCommand(spawnCommand, args), - copilotAcp: isCopilotAcpCommand(spawnCommand, args), - claudeAcp: isClaudeAcpCommand(spawnCommand, args), -- spawnOptions: buildAgentSpawnOptions(this.options.cwd, this.options.authCredentials, this.options.sessionOptions?.env) -+ spawnOptions: buildAgentSpawnOptions(this.options.spawnCwd ?? this.options.cwd, this.options.authCredentials, this.options.sessionOptions?.env, this.options.inheritProcessEnv) - }; - } - logAgentLaunch(plan) { -@@ -4729,6 +4749,9 @@ - return await this.filesystem.readTextFile(params); - } catch (error) { - this.recordPermissionError(params.sessionId, error); -+ // Keep ENOENT distinct from permission and other filesystem failures. -+ // The ACP peer must receive a resource error before it creates a file. -+ if (error && typeof error === "object" && error.code === "ENOENT") throw RequestError.resourceNotFound(params.path); - throw error; - } - } -@@ -6070,6 +6093,27 @@ - //#region src/runtime/engine/prompt-turn.ts - const SESSION_REPLY_IDLE_MS = 1e3; - const SESSION_REPLY_DRAIN_TIMEOUT_MS = 5e3; -+const TYPED_SESSION_FAILURE_CATEGORIES = /* @__PURE__ */ new Set([ -+ "connection", -+ "access", -+ "limit", -+ "service", -+ "request", -+ "unknown" -+]); -+function typedTerminalSessionFailureCategory(response) { -+ if (response === null || typeof response !== "object" || Array.isArray(response)) return null; -+ const meta = response._meta; -+ if (meta === null || typeof meta !== "object" || Array.isArray(meta)) return null; -+ const jetbrains = meta.jetbrains; -+ if (jetbrains === null || typeof jetbrains !== "object" || Array.isArray(jetbrains)) return null; -+ const air = jetbrains.air; -+ if (air === null || typeof air !== "object" || Array.isArray(air)) return null; -+ if (!Number.isInteger(air.version) || air.version < 1) return null; -+ const failure = air.sessionFailure; -+ if (failure === null || typeof failure !== "object" || Array.isArray(failure) || failure.severity !== "error") return null; -+ return typeof failure.category === "string" && TYPED_SESSION_FAILURE_CATEGORIES.has(failure.category) ? failure.category : "unknown"; -+} - async function runPromptTurn(params) { - try { - const promptPromise = params.client.prompt(params.sessionId, params.prompt); -@@ -6079,6 +6123,20 @@ - idleMs: SESSION_REPLY_IDLE_MS, - timeoutMs: SESSION_REPLY_DRAIN_TIMEOUT_MS - }).catch(() => {}); -+ const terminalFailureCategory = typedTerminalSessionFailureCategory(response); -+ if (terminalFailureCategory !== null) { -+ // Pass complete provider text to the diagnostic callback. Consumers redact -+ // before bounding it; truncating here can split and expose a credential. -+ const failure = response._meta.jetbrains.air.sessionFailure; -+ try { -+ params.onTerminalSessionFailure?.({ -+ category: terminalFailureCategory, -+ ...(typeof failure.title === "string" ? { title: failure.title } : {}), -+ ...(typeof failure.details === "string" ? { details: failure.details } : {}) -+ }); -+ } catch {} -+ throw new Error(`ACP agent reported a terminal ${terminalFailureCategory} failure.`); -+ } - recordPromptResponseUsage(params.conversation, response.usage, params.promptMessageId); - return { - stopReason: response.stopReason, -diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts -index ccdbe5b032521518022223733049b8b38793473b..416bdaae1450c6e85b34afd154349770f9f7b0a9 100644 ---- a/dist/runtime.d.ts -+++ b/dist/runtime.d.ts -@@ -45,6 +45,7 @@ type AcpRuntimeTurnAttachment = { - data: string; - }; - type AcpRuntimeTurnInput = { -+ onTerminalSessionFailure?: (failure: { category: string; title?: string; details?: string }) => void; - handle: AcpRuntimeHandle; - text: string; - attachments?: AcpRuntimeTurnAttachment[]; -@@ -266,6 +267,10 @@ type AcpRuntimeOptions = { - timeoutMs?: number; - probeAgent?: string; - verbose?: boolean; -+ onAgentStderr?: (chunk: string) => void; -+ onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise; -+ spawnCwd?: string; -+ inheritProcessEnv?: boolean; - onPermissionRequest?: (req: AcpPermissionRequest, ctx: { - signal: AbortSignal; - }) => Promise; -@@ -302,6 +307,7 @@ declare class AcpRuntimeManager { - private createAndSaveRuntimeRecord; - private keepPersistentClient; - startTurn(input: { -+ onTerminalSessionFailure?: (failure: { category: string; title?: string; details?: string }) => void; - handle: AcpRuntimeHandle; - text: string; - attachments?: AcpRuntimeTurnAttachment[]; -@@ -334,6 +340,7 @@ declare class AcpRuntimeManager { - private finalizeRuntimeTurn; - private finalizeRuntimeTurnRecord; - runTurn(input: { -+ onTerminalSessionFailure?: (failure: { category: string; title?: string; details?: string }) => void; - handle: AcpRuntimeHandle; - text: string; - attachments?: AcpRuntimeTurnAttachment[]; -diff --git a/dist/runtime.js b/dist/runtime.js -index 6c9cc999e50a11c399c68b3a0f1b7af4bc2317c0..e06bf4fdee534f3ef56be0387a2f5f8626e697ad 100644 ---- a/dist/runtime.js -+++ b/dist/runtime.js -@@ -744,7 +744,8 @@ var AcpRuntimeManager = class { - this.deps = deps; - } - createClient(options) { -- return this.deps.clientFactory?.(options) ?? new AcpClient(options); -+ const clientOptions = { ...options, onAgentStderr: this.options.onAgentStderr, onAgentSpawn: this.options.onAgentSpawn, spawnCwd: this.options.spawnCwd, inheritProcessEnv: this.options.inheritProcessEnv }; -+ return this.deps.clientFactory?.(clientOptions) ?? new AcpClient(clientOptions); - } - async readPendingPersistentClient(record, options) { - const pendingClient = this.pendingPersistentClients.get(record.acpxRecordId); -@@ -982,6 +983,7 @@ var AcpRuntimeManager = class { - client: turn.client, - sessionId, - prompt: task.promptInput, -+ onTerminalSessionFailure: task.input.onTerminalSessionFailure, - timeoutMs: task.input.timeoutMs ?? this.options.timeoutMs, - conversation: turn.conversation, - promptMessageId: turn.promptMessageId -@@ -1586,6 +1588,7 @@ var AcpxRuntime = class { - const turnPromise = this.getManager().then((manager) => manager.startTurn({ - handle, - text: input.text, -+ onTerminalSessionFailure: input.onTerminalSessionFailure, - attachments: input.attachments, - mode: input.mode, - sessionMode: state.mode, -@@ -1614,6 +1617,7 @@ var AcpxRuntime = class { - yield* (await this.getManager()).runTurn({ - handle, - text: input.text, -+ onTerminalSessionFailure: input.onTerminalSessionFailure, - attachments: input.attachments, - mode: input.mode, - sessionMode: state.mode, -diff --git a/dist/session-options-jkYbBxGE.d.ts b/dist/session-options-jkYbBxGE.d.ts -index 9d37f377fb6a0828e0d2bc5a48754f3aa71509a4..22dda59e0b7616a20ad45218913e5d8f884bae49 100644 ---- a/dist/session-options-jkYbBxGE.d.ts -+++ b/dist/session-options-jkYbBxGE.d.ts -@@ -84,6 +84,10 @@ type AcpClientOptions = { - terminal?: boolean; - suppressSdkConsoleErrors?: boolean; - verbose?: boolean; -+ onAgentStderr?: (chunk: string) => void; -+ onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise; -+ spawnCwd?: string; -+ inheritProcessEnv?: boolean; - sessionOptions?: { - model?: string; - allowedTools?: string[]; diff --git a/patches/acpx@0.13.1.patch b/patches/acpx@0.13.1.patch deleted file mode 100644 index f9b5f3403d3..00000000000 --- a/patches/acpx@0.13.1.patch +++ /dev/null @@ -1,1394 +0,0 @@ -diff --git a/dist/client-CxNllqui.d.ts b/dist/client-CxNllqui.d.ts -index 5e2113ac0a1a92c99322cf01e5c106760a0b0dbf..45aff03c37427071de49b45eb0361a9a808786e0 100644 ---- a/dist/client-CxNllqui.d.ts -+++ b/dist/client-CxNllqui.d.ts -@@ -120,6 +120,8 @@ declare class AcpClient { - private initializeProtocolConnection; - private handleInitializeFailure; - private createTappedStream; -+ private cursorSubagentStreamOwner; -+ private handleCursorSubagentNotification; - createSession(cwd?: string): Promise; - loadSession(sessionId: string, cwd?: string): Promise; - loadSessionWithOptions(sessionId: string, cwd?: string, options?: LoadSessionOptions): Promise; -@@ -135,6 +137,7 @@ declare class AcpClient { - private throwPromptPermissionFailureIfPresent; - setSessionMode(sessionId: string, modeId: string): Promise; - setSessionConfigOption(sessionId: string, configId: string, value: string): Promise; -+ requestExtension(method: string, params: Record): Promise>; - setSessionModel(sessionId: string, modelId: string, controlOverride?: ModelControlOverride): Promise; - private setSessionModelThroughConfig; - private setSessionModelThroughLegacyMethod; -diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js -index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..25d957b502a8680ff3e4d64356dc32bf99684b7a 100644 ---- a/dist/live-checkpoint-BSIrfgVo.js -+++ b/dist/live-checkpoint-BSIrfgVo.js -@@ -6,7 +6,7 @@ import os from "node:os"; - import { randomUUID } from "node:crypto"; - import { execFile, spawn } from "node:child_process"; - import { Readable, Writable } from "node:stream"; --import { PROTOCOL_VERSION, client, methods } from "@agentclientprotocol/sdk"; -+import { PROTOCOL_VERSION, RequestError, client, methods } from "@agentclientprotocol/sdk"; - import readline from "node:readline/promises"; - //#region src/errors.ts - var AcpxOperationalError = class extends Error { -@@ -1068,12 +1068,14 @@ function serializeSessionRecordForDisk(record) { - last_agent_disconnect_reason: canonical.lastAgentDisconnectReason, - protocol_version: canonical.protocolVersion, - agent_capabilities: canonical.agentCapabilities, -+ agent_goal_capability: canonical.agentGoalCapability, - title: canonical.title, - messages: canonical.messages, - updated_at: canonical.updated_at, - cumulative_token_usage: canonical.cumulative_token_usage, - cumulative_cost: canonical.cumulative_cost, - request_token_usage: canonical.request_token_usage, -+ cursor_prompt_usage: boundCursorPromptUsage(canonical.cursor_prompt_usage, canonical.messages), - acpx: canonical.acpx, - imported_from: canonical.importedFrom ? { - record_id: canonical.importedFrom.recordId, -@@ -1239,7 +1241,8 @@ function parseRequestTokenUsage(raw) { - for (const [key, value] of Object.entries(record)) { - const parsed = parseTokenUsage(value); - if (parsed == null) return null; -- usage[key] = parsed; -+ const receipt = parsePaperclipPiReceipt(asRecord$4(value)?.paperclip_pi); -+ usage[key] = receipt ? { ...parsed, paperclip_pi: receipt } : parsed; - } - return usage; - } -@@ -1325,6 +1328,93 @@ function isAgentMessage$1(raw) { - function isConversationMessage(raw) { - return raw === "Resume" || isUserMessage$1(raw) || isAgentMessage$1(raw); - } -+function parseCursorPromptUsage(raw) { -+ try { -+ const text = JSON.stringify(raw); -+ if (!text || Buffer.byteLength(text, "utf8") > 16384) return null; -+ const v = JSON.parse(text); -+ const object = (x)=>x !== null && typeof x === "object" && !Array.isArray(x); -+ const exact = (x, keys)=>Object.keys(x).length === keys.length && keys.every((k)=>Object.hasOwn(x, k)); -+ const id = (x)=>typeof x === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,239}$/.test(x); -+ if (!object(v) || !exact(v, [ -+ "request_id", -+ "prompt_message_id", -+ "receipt" -+ ]) || !id(v.request_id) || !id(v.prompt_message_id)) return null; -+ const r = v.receipt; -+ if (!object(r) || !exact(r, [ -+ "schema", -+ "source", -+ "promptId", -+ "completeness", -+ "reasons", -+ "observations", -+ "limits", -+ "truncated" -+ ])) return null; -+ if (r.schema !== "paperclip.cursor.native-usage.v1" || r.source !== "native_turn_ended" || r.completeness !== "partial" || typeof r.promptId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(r.promptId) || typeof r.truncated !== "boolean") return null; -+ const reasons = [ -+ "native_counter_semantics_unverified", -+ "native_counters_missing", -+ "invalid_native_counter", -+ "multiple_terminal_observations", -+ "native_terminal_not_observed", -+ "observation_limit_reached", -+ "child_run_attribution_unverified" -+ ]; -+ if (!Array.isArray(r.reasons) || r.reasons.length > reasons.length || !r.reasons.includes(reasons[0]) || new Set(r.reasons).size !== r.reasons.length || !r.reasons.every((x)=>reasons.includes(x))) return null; -+ if (!object(r.limits) || !exact(r.limits, [ -+ "maxObservations", -+ "maxInvocations", -+ "maxBytes" -+ ]) || r.limits.maxObservations !== 64 || r.limits.maxInvocations !== 64 || r.limits.maxBytes !== 16384 || !Array.isArray(r.observations) || r.observations.length > 64) return null; -+ const fields = [ -+ "inputTokens", -+ "outputTokens", -+ "cacheReadTokens", -+ "cacheWriteTokens", -+ "reasoningTokens" -+ ]; -+ const invocations = new Map(); -+ for (const o of r.observations){ -+ if (!object(o) || !exact(o, [ -+ "invocationId", -+ "role", -+ "nativeRun", -+ "sequence", -+ "counters" -+ ]) || typeof o.invocationId !== "string" || !/^invocation-([1-9]|[1-5][0-9]|6[0-4])$/.test(o.invocationId) || o.role !== "parent" && o.role !== "child" || !Number.isSafeInteger(o.nativeRun) || o.nativeRun < 1 || !Number.isSafeInteger(o.sequence) || o.sequence < 1 || !object(o.counters)) return null; -+ if (!Object.entries(o.counters).every(([k, n])=>fields.includes(k) && Number.isSafeInteger(n) && n >= 0)) return null; -+ const previous = invocations.get(o.invocationId); -+ if (previous && (previous.role !== o.role || previous.nativeRun !== o.nativeRun || o.sequence <= previous.sequence)) return null; -+ invocations.set(o.invocationId, { -+ role: o.role, -+ nativeRun: o.nativeRun, -+ sequence: o.sequence -+ }); -+ } -+ return v; -+ } catch { -+ return null; -+ } -+} -+function boundCursorPromptUsage(raw, messages) { -+ const receipt = parseCursorPromptUsage(raw); -+ if (!receipt || !Array.isArray(messages) || messages.filter(message => message?.User?.id === receipt.prompt_message_id).length !== 1) return undefined; -+ return receipt; -+} -+function recordCursorPromptUsage(conversation, response, requestId, promptMessageId) { -+ // Optional diagnostics cannot affect settlement or standard token/cost accounting. -+ try { -+ if (response.stopReason !== "end_turn" && response.stopReason !== "cancelled") return; -+ const next = boundCursorPromptUsage({request_id: requestId, prompt_message_id: promptMessageId, receipt: response._meta?.paperclipCursorUsage}, conversation.messages); -+ if (!next) return; -+ const previous = parseCursorPromptUsage(conversation.cursor_prompt_usage); -+ if (previous && (previous.request_id === next.request_id || previous.prompt_message_id === next.prompt_message_id || previous.receipt.promptId === next.receipt.promptId)) return; -+ conversation.cursor_prompt_usage = next; -+ } catch {} -+} -+ - function parseConversationRecord(record) { - if (!hasValidConversationCore(record)) return; - const title = parseConversationTitle(record.title); -@@ -1339,7 +1429,8 @@ function parseConversationRecord(record) { - updated_at: record.updated_at, - cumulative_token_usage: cumulativeTokenUsage ?? {}, - cumulative_cost: cumulativeCost, -- request_token_usage: requestTokenUsage ?? {} -+ request_token_usage: requestTokenUsage ?? {}, -+ cursor_prompt_usage: boundCursorPromptUsage(record.cursor_prompt_usage, record.messages) - }; - } - const INVALID_VALUE = Symbol("invalid"); -@@ -1541,12 +1632,14 @@ function parseSessionRecord(raw) { - lastAgentDisconnectReason: optionals.lastAgentDisconnectReason, - protocolVersion: typeof record.protocol_version === "number" ? record.protocol_version : void 0, - agentCapabilities: asRecord$4(record.agent_capabilities), -+ agentGoalCapability: asRecord$4(record.agent_goal_capability), - title: conversation.title, - messages: conversation.messages, - updated_at: conversation.updated_at, - cumulative_token_usage: conversation.cumulative_token_usage, - cumulative_cost: conversation.cumulative_cost, - request_token_usage: conversation.request_token_usage, -+ cursor_prompt_usage: conversation.cursor_prompt_usage, - acpx: parseAcpxState(record.acpx), - importedFrom: metadata.importedFrom - }; -@@ -1661,8 +1754,9 @@ const ZED_TAG_KEYS = /* @__PURE__ */ new Set([ - "RedactedThinking", - "ToolUse" - ]); --const MAP_OBJECT_PATHS = /* @__PURE__ */ new Set(["request_token_usage", "messages.Agent.tool_results"]); -+const MAP_OBJECT_PATHS = /* @__PURE__ */ new Set(["request_token_usage", "messages.Agent.tool_results", "acpx.session_options.env"]); - const OPAQUE_VALUE_PATHS = /* @__PURE__ */ new Set([ -+ "cursor_prompt_usage.receipt", - "agent_capabilities", - "messages.Agent.content.ToolUse.input", - "acpx.desired_config_options", -@@ -3104,10 +3198,10 @@ function authEnvKey(methodId) { - authEnvKeyCache.set(methodId, key); - return key; - } --function readEnvCredential(methodId) { -+function readEnvCredential(methodId, environment = process.env) { - const key = authEnvKey(methodId); - if (!key) return; -- const value = process.env[key]; -+ const value = environment[key]; - if (typeof value === "string" && value.trim().length > 0) return value; - } - function protectedEnvKey(key) { -@@ -3135,8 +3229,21 @@ function promotePrefixedAuthEnvironment(env) { - } - return protectedKeys; - } --function buildAgentEnvironment(authCredentials, sessionEnv) { -- const env = { ...process.env }; -+function isPlainStringEnvironment(value) { -+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false; -+ const prototype = Object.getPrototypeOf(value); -+ return (prototype === Object.prototype || prototype === null) && Object.values(value).every((entry) => typeof entry === "string"); -+} -+function resolveAgentEnvironment(spawnEnvironment) { -+ let sourceEnvironment = process.env; -+ if (spawnEnvironment !== void 0) { -+ sourceEnvironment = spawnEnvironment(); -+ if (!isPlainStringEnvironment(sourceEnvironment)) throw new TypeError("ACPX spawn environment must be a plain record of string values"); -+ } -+ return sourceEnvironment; -+} -+function buildAgentEnvironment(authCredentials, sessionEnv, spawnEnvironment) { -+ const env = { ...resolveAgentEnvironment(spawnEnvironment) }; - const protectedAuthEnvKeys = promotePrefixedAuthEnvironment(env); - if (authCredentials) for (const [methodId, credential] of Object.entries(authCredentials)) { - addAuthCredentialEnvKeys(protectedAuthEnvKeys, methodId, credential); -@@ -3178,10 +3285,10 @@ function resolveConfiguredAuthCredential(methodId, authCredentials) { - const configCredentials = authCredentials ?? {}; - return configCredentials[methodId] ?? configCredentials[toEnvToken(methodId)]; - } --function buildAgentSpawnOptions(cwd, authCredentials, sessionEnv) { -+function buildAgentSpawnOptions(cwd, authCredentials, sessionEnv, spawnEnvironment) { - return { - cwd, -- env: buildAgentEnvironment(authCredentials, sessionEnv), -+ env: buildAgentEnvironment(authCredentials, sessionEnv, spawnEnvironment), - stdio: [ - "pipe", - "pipe", -@@ -3286,28 +3393,12 @@ function formatAvailableModelIds(models) { - const ids = models?.availableModels.map((model) => model.modelId.trim()).filter((modelId) => modelId.length > 0) ?? []; - return ids.length > 0 ? ids.join(", ") : "none advertised"; - } --function resolveRequestedModelId(params) { -- if (!params.models || !isCursorAcpCommandForModelAlias(params.agentCommand)) return params.requestedModel; -- if (params.models.availableModels.some((model) => model.modelId === params.requestedModel)) return params.requestedModel; -- const candidates = params.models.availableModels.map((model) => model.modelId).filter((modelId) => modelId.startsWith(`${params.requestedModel}[`)); -- return candidates.length === 1 ? candidates[0] : params.requestedModel; --} --function isCursorAcpCommandForModelAlias(agentCommand) { -- if (!agentCommand) return false; -- const { command, args } = splitCommandLine(agentCommand); -- return isCursorAcpCommand(command, args); --} - function assertRequestedModelSupported(params) { - if (!params.models) { - if (supportsLegacyClaudeCodeModelMetadata(params.agentCommand)) return; - throw new RequestedModelUnsupportedError(`Cannot ${params.context === "replay" ? "replay saved model" : "apply --model"} "${params.requestedModel}": the ACP agent did not advertise model support through a session config option or legacy models metadata, and the adapter does not support a startup model flag.`, "missing-capability"); - } -- if (!new Set(params.models.availableModels.map((model) => model.modelId)).has(params.requestedModel)) { -- const resolvedModel = resolveRequestedModelId(params); -- if (resolvedModel !== params.requestedModel) return `Cursor ACP advertised "${resolvedModel}" for requested model "${params.requestedModel}"; using the advertised id.`; -- if (supportsLegacyClaudeCodeModelMetadata(params.agentCommand)) return `requested model "${params.requestedModel}" was not in the Claude ACP advertised model list (${formatAvailableModelIds(params.models)}); forwarding it to Claude Code so the adapter can accept or reject it.`; -- throw new RequestedModelUnsupportedError(`Cannot ${params.context === "replay" ? "replay saved model" : "apply --model"} "${params.requestedModel}": the ACP agent did not advertise that model. Available models: ${formatAvailableModelIds(params.models)}.`, "unadvertised-model"); -- } -+ // Discovery catalogs may be incomplete; the provider accepts or rejects the exact ID. - } - //#endregion - //#region src/acp/session-control-errors.ts -@@ -3961,10 +4052,13 @@ function resolveClientCapabilities(params) { - ...params.elicitationModes.includes("url") ? { url: {} } : {} - } } : {} - }; -- if (!params.devinAcp) return baseCapabilities; -+ const typedSessionFailureMeta = { -+ jetbrains: { air: { version: 1, capabilities: ["sessionFailure"] } } -+ }; -+ if (!params.devinAcp) return { ...baseCapabilities, _meta: typedSessionFailureMeta }; - return { - ...baseCapabilities, -- _meta: DEVIN_COMPATIBILITY_CLIENT_CAPABILITIES_META -+ _meta: { ...typedSessionFailureMeta, ...DEVIN_COMPATIBILITY_CLIENT_CAPABILITIES_META } - }; - } - function hasResponseField(response, field) { -@@ -4067,6 +4161,27 @@ function createNdJsonMessageStream(agentCommand, output, input) { - } }) - }; - } -+function boundedExtensionRecord(value) { -+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new TypeError("ACP extension payload must be an object"); -+ const prototype = Object.getPrototypeOf(value); -+ if (prototype !== Object.prototype && prototype !== null) throw new TypeError("ACP extension payload must be a plain object"); -+ const serialized = JSON.stringify(value); -+ if (Buffer.byteLength(serialized) > 256 * 1024) throw new TypeError("ACP extension payload exceeds its bounded size"); -+ return JSON.parse(serialized); -+} -+function closedExtensionMethods(value) { -+ if (value === void 0) return Object.freeze([]); -+ if (!Array.isArray(value) || value.length > 64) throw new TypeError("ACP extension methods must be a bounded allowlist"); -+ for (const method of value) { -+ if (typeof method !== "string" || !/^[A-Za-z_][A-Za-z0-9._/-]{1,255}$/.test(method) || !method.includes("/") || /^(?:session|fs|terminal|elicitation|notifications)\//.test(method)) throw new TypeError("ACP extension method cannot override a standard method"); -+ } -+ return Object.freeze([...new Set(value)]); -+} -+function mergeRuntimeClientCapabilities(base, extra) { -+ if (extra === void 0) return base; -+ const additional = boundedExtensionRecord(extra); -+ return { ...additional, ...base, _meta: { ...additional._meta, ...base._meta } }; -+} - var AcpClient = class { - options; - connection; -@@ -4105,7 +4220,9 @@ var AcpClient = class { - cwd: asAbsoluteCwd(options.cwd), - authPolicy: options.authPolicy ?? "skip", - permissionPolicy: snapshotPermissionPolicy(options.permissionPolicy), -- elicitationModes: normalizeElicitationModes(options.elicitationModes) -+ elicitationModes: normalizeElicitationModes(options.elicitationModes), -+ extensionMethods: closedExtensionMethods(options.extensionMethods), -+ clientCapabilities: options.clientCapabilities === void 0 ? void 0 : boundedExtensionRecord(options.clientCapabilities) - }; - this.eventHandlers = { - onAcpMessage: this.options.onAcpMessage, -@@ -4216,9 +4333,21 @@ var AcpClient = class { - this.lastAgentExit = void 0; - this.lastKnownPid = child.pid ?? void 0; - this.attachAgentLifecycleObservers(child); -+ if (this.options.onAgentSpawn) { -+ try { -+ if (!child.pid) throw new Error("ACPX agent spawn did not expose a valid process id."); -+ await this.options.onAgentSpawn({ pid: child.pid, startedAt: this.agentStartedAt }); -+ } catch (error) { -+ try { -+ child.kill("SIGKILL"); -+ } catch {} -+ throw error; -+ } -+ } - const startupStderr = []; - child.stderr.on("data", (chunk) => { - this.captureStartupStderr(startupStderr, chunk); -+ this.options.onAgentStderr?.(String(chunk)); - if (!this.options.verbose) return; - process.stderr.write(chunk); - }); -@@ -4226,6 +4355,7 @@ var AcpClient = class { - const output = Readable.toWeb(child.stdout); - const stream = this.createTappedStream(createNdJsonMessageStream(this.options.agentCommand, input, output)); - const connection = this.createConnection(stream, launch); -+ connection.signal.addEventListener("abort", () => stream.abortResponseDeliveries(), { once: true }); - connection.signal.addEventListener("abort", () => { - this.recordAgentExit("connection_close", child.exitCode ?? null, child.signalCode ?? null); - }, { once: true }); -@@ -4253,7 +4383,12 @@ var AcpClient = class { - geminiAcp: isGeminiAcpCommand(spawnCommand, args), - copilotAcp: isCopilotAcpCommand(spawnCommand, args), - claudeAcp: isClaudeAcpCommand(spawnCommand, args), -- spawnOptions: buildAgentSpawnOptions(this.options.cwd, this.options.authCredentials, this.options.sessionOptions?.env) -+ spawnOptions: buildAgentSpawnOptions( -+ this.options.spawnCwd ?? this.options.cwd, -+ this.options.authCredentials, -+ this.options.sessionOptions?.env, -+ this.options.spawnEnvironment -+ ) - }; - } - logAgentLaunch(plan) { -@@ -4279,10 +4414,17 @@ var AcpClient = class { - } - async spawnAgentProcess(plan) { - const spawnCommand = buildAgentSpawnCommand(plan.spawnCommand, plan.args, process.platform, plan.spawnOptions.env); -- const spawnedChild = spawn(spawnCommand.command, spawnCommand.args, { -+ const options = { - ...plan.spawnOptions, - windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments -- }); -+ }; -+ const spawnedChild = this.options.spawnAgent -+ ? this.options.spawnAgent({ -+ command: spawnCommand.command, -+ args: spawnCommand.args, -+ options -+ }) -+ : spawn(spawnCommand.command, spawnCommand.args, options); - try { - await waitForSpawn$1(spawnedChild); - } catch (error) { -@@ -4293,10 +4435,10 @@ var AcpClient = class { - createConnection(stream, launch) { - const app = client({ name: "acpx" }).onNotification(methods.client.session.update, async ({ params }) => { - await this.handleSessionUpdate(params); -- }).onNotification(methods.client.elicitation.complete, async () => {}).onRequest(methods.client.session.requestPermission, async ({ params }) => { -- return await this.handlePermissionRequest(params); -+ }).onNotification(methods.client.elicitation.complete, async () => {}).onRequest(methods.client.session.requestPermission, async ({ params, requestId, signal }) => { -+ return await stream.withResponseDelivery(requestId, signal, (responseDelivery) => this.handlePermissionRequest(params, responseDelivery)); - }).onRequest(methods.client.elicitation.create, async ({ params, requestId, signal }) => { -- return await this.handleElicitationRequest(params, requestId, signal); -+ return await stream.withResponseDelivery(requestId, signal, (responseDelivery) => this.handleElicitationRequest(params, requestId, signal, responseDelivery)); - }).onRequest(methods.client.fs.readTextFile, async ({ params }) => { - return await this.handleReadTextFile(params); - }).onRequest(methods.client.fs.writeTextFile, async ({ params }) => { -@@ -4312,6 +4454,11 @@ var AcpClient = class { - }).onRequest(methods.client.terminal.release, async ({ params }) => { - return await this.handleReleaseTerminal(params); - }); -+ for (const method of this.options.extensionMethods) { -+ if (this.options.onExtensionRequest) app.onRequest(method, boundedExtensionRecord, async ({ params, requestId, signal }) => { -+ return await stream.withResponseDelivery(requestId, signal, (responseDelivery) => this.handleExtensionRequest(method, params, requestId, signal, responseDelivery)); -+ }); -+ } - if (launch.devinAcp) app.onRequest("_cognition.ai/request_diagnostics", (params) => { - return params && typeof params === "object" && !Array.isArray(params) ? params : {}; - }, async () => ({})); -@@ -4333,12 +4480,12 @@ var AcpClient = class { - async initializeProtocolConnection(connection, launch) { - const initializePromise = connection.initialize({ - protocolVersion: PROTOCOL_VERSION, -- clientCapabilities: resolveClientCapabilities({ -+ clientCapabilities: mergeRuntimeClientCapabilities(resolveClientCapabilities({ - devinAcp: launch.devinAcp, - fs: this.options.fs !== false, - terminal: this.options.terminal !== false, - elicitationModes: this.options.elicitationModes ?? [] -- }), -+ }), this.options.clientCapabilities), - clientInfo: resolveClientInfo(launch.devinAcp) - }); - const initialized = launch.geminiAcp ? await withTimeout(initializePromise, resolveGeminiAcpStartupTimeoutMs()) : await initializePromise; -@@ -4358,6 +4505,49 @@ var AcpClient = class { - throw normalizedError; - } - createTappedStream(base) { -+ // Policy admission is distinct from best-effort observation. Each new -+ // stdio connection gets fresh authority and a violation tears it down. -+ const protocolGuard = this.options.protocolGuardFactory?.(); -+ const enforceProtocol = (direction, message) => { -+ try { protocolGuard?.(direction, message); } -+ catch (error) { -+ this.rejectPendingConnectionRequests(error); -+ abortResponseDeliveries(); -+ this.activePrompt?.elicitationController.abort(error); -+ try { this.agent?.kill("SIGTERM"); } catch {} -+ throw error; -+ } -+ }; -+ const deliveries = new Map(); -+ const scope = new AbortController(); -+ const abortResponseDeliveries = () => scope.abort(new Error("ACP response connection closed before delivery")); -+ const withResponseDelivery = async (id, requestSignal, handler) => { -+ if (!(id === null || typeof id === "string" || typeof id === "number" && Number.isFinite(id))) throw new Error("ACP response request id is invalid"); -+ if (deliveries.has(id) || deliveries.size >= 128) { -+ abortResponseDeliveries(); -+ throw new Error("ACP response request id is duplicated or pending deliveries exceeded their bound"); -+ } -+ const active = this.activePrompt; -+ const signal = AbortSignal.any([scope.signal, requestSignal, ...(active ? [active.elicitationController.signal] : [])]); -+ let settle, reject; -+ const responseDelivery = new Promise((resolve, fail) => { settle = resolve; reject = fail; }); -+ // The host may decline the request without awaiting its receipt. -+ void responseDelivery.catch(() => {}); -+ const abort = () => reject(new Error("ACP response delivery was cancelled or disconnected")); -+ const receipt = { settle, reject, expected: void 0, cleanup: () => signal.removeEventListener("abort", abort) }; -+ deliveries.set(id, receipt); -+ signal.addEventListener("abort", abort, { once: true }); -+ if (signal.aborted) abort(); -+ try { -+ const result = await handler(responseDelivery); -+ if (signal.aborted || this.activePrompt !== active) throw new Error("ACP response outlived its admitted prompt"); -+ receipt.expected = JSON.stringify(result); -+ return result; -+ } catch (error) { -+ reject(new Error("ACP request failed before response delivery")); -+ throw error; -+ } -+ }; - const onAcpMessage = () => this.eventHandlers.onAcpMessage; - const onAcpOutputMessage = () => this.eventHandlers.onAcpOutputMessage; - const elicitationRequestIds = /* @__PURE__ */ new Set(); -@@ -4367,8 +4557,12 @@ var AcpClient = class { - const shouldSuppressInboundReplaySessionUpdate = (message) => { - return this.suppressReplaySessionUpdateMessages && isSessionUpdateNotification(message); - }; -+ const cursorSubagentOwner = { active: null, children: new Map() }; -+ this.cursorSubagentStreamOwner = cursorSubagentOwner; -+ const onCursorSubagentNotification = (message) => this.handleCursorSubagentNotification(message, cursorSubagentOwner); -+ const onExtensionNotification = (message) => this.handleExtensionNotification(message); - const observeInbound = (message) => { -- const requestId = elicitationRequestId(message); -+ const requestId = elicitationRequestId(message) ?? ("id" in message && this.options.extensionMethods.includes(message.method) ? message.id : void 0); - if (requestId !== void 0) { - elicitationRequestIds.add(requestId); - return; -@@ -4379,6 +4573,7 @@ var AcpClient = class { - } - }; - return { -+ withResponseDelivery, abortResponseDeliveries, - readable: new ReadableStream({ async start(controller) { - const reader = base.readable.getReader(); - try { -@@ -4386,16 +4581,27 @@ var AcpClient = class { - const { value, done } = await reader.read(); - if (done) break; - if (!value) continue; -+ enforceProtocol("inbound", value); - observeInbound(value); -- if (isExtensionNotification(value)) continue; -+ if (onCursorSubagentNotification(value)) continue; -+ if (isExtensionNotification(value)) { -+ onExtensionNotification(value); -+ continue; -+ } - controller.enqueue(value); - } -+ controller.close(); -+ } catch (error) { -+ controller.error(error); - } finally { -+ abortResponseDeliveries(); -+ for (const receipt of deliveries.values()) receipt.cleanup(); -+ deliveries.clear(); - reader.releaseLock(); -- controller.close(); - } - } }), - writable: new WritableStream({ async write(message) { -+ enforceProtocol("outbound", message); - const promptOwner = promptRequestOwner(message); - if (promptOwner) bindPromptOwner(promptOwner); - const id = responseId(message); -@@ -4403,10 +4609,20 @@ var AcpClient = class { - onAcpOutputMessage()?.("outbound", message); - onAcpMessage()?.("outbound", message); - } -+ const receipt = id === void 0 ? void 0 : deliveries.get(id); - const writer = base.writable.getWriter(); - try { - await writer.write(message); -+ if (receipt) { -+ if (!("result" in message) || receipt.expected === void 0 || JSON.stringify(message.result) !== receipt.expected) receipt.reject(new Error("ACP wrote an error or changed response instead of the selected resolution")); -+ else receipt.settle(); -+ } -+ } catch (error) { -+ receipt?.reject(new Error("ACP response write failed")); -+ abortResponseDeliveries(); -+ throw error; - } finally { -+ if (receipt) { receipt.cleanup(); deliveries.delete(id); } - writer.releaseLock(); - } - } }) -@@ -4422,7 +4638,10 @@ var AcpClient = class { - const createPromise = this.runConnectionRequest(() => connection.newSession({ - cwd: sessionCwd, - mcpServers: this.options.mcpServers ?? [], -- _meta: buildClaudeCodeOptionsMeta(this.options.sessionOptions, claudeAcp) -+ _meta: this.isGrokBuildAcpCommand() -+ ? { rules: typeof this.options.sessionOptions?.systemPrompt === "string" -+ ? this.options.sessionOptions.systemPrompt : this.options.sessionOptions?.systemPrompt?.append ?? "" } -+ : buildClaudeCodeOptionsMeta(this.options.sessionOptions, claudeAcp) - })); - result = claudeAcp ? await withTimeout(createPromise, resolveClaudeAcpSessionCreateTimeoutMs()) : await createPromise; - } catch (error) { -@@ -4454,6 +4673,7 @@ var AcpClient = class { - } - async loadSessionWithOptions(sessionId, cwd = this.options.cwd, options = {}) { - const connection = this.getConnection(); -+ const { command, args } = resolveAgentCommandParts(this.options.agentCommand, this.options.agentArgv); - const sessionCwd = await resolveAgentSessionCwd(cwd, this.options.agentCommand); - const previousSuppression = this.applySessionUpdateSuppression(Boolean(options.suppressReplayUpdates)); - let response; -@@ -4461,7 +4681,9 @@ var AcpClient = class { - response = await this.runConnectionRequest(() => connection.loadSession({ - sessionId, - cwd: sessionCwd, -- mcpServers: this.options.mcpServers ?? [] -+ mcpServers: this.options.mcpServers ?? [], -+ _meta: isClaudeAcpCommand(command, args) -+ ? buildClaudeCodeOptionsMeta(this.options.sessionOptions, true) : void 0 - })); - await this.waitForSessionUpdateDrain(options.replayIdleMs ?? REPLAY_IDLE_MS, options.replayDrainTimeoutMs ?? REPLAY_DRAIN_TIMEOUT_MS); - } finally { -@@ -4474,11 +4696,14 @@ var AcpClient = class { - } - async resumeSession(sessionId, cwd = this.options.cwd) { - const connection = this.getConnection(); -+ const { command, args } = resolveAgentCommandParts(this.options.agentCommand, this.options.agentArgv); - const sessionCwd = await resolveAgentSessionCwd(cwd, this.options.agentCommand); - const response = await this.runConnectionRequest(() => connection.resumeSession({ - sessionId, - cwd: sessionCwd, -- mcpServers: this.options.mcpServers ?? [] -+ mcpServers: this.options.mcpServers ?? [], -+ _meta: isClaudeAcpCommand(command, args) -+ ? buildClaudeCodeOptionsMeta(this.options.sessionOptions, true) : void 0 - })); - this.loadedSessionId = sessionId; - const result = toReconnectedSessionResult(response); -@@ -4590,15 +4815,17 @@ var AcpClient = class { - throw maybeWrapSessionControlError("session/set_config_option", error, `for "${configId}"="${value}"`); - } - } -+ async requestExtension(method, params) { -+ closedExtensionMethods([method]); -+ const payload = boundedExtensionRecord(params); -+ const connection = this.getConnection(); -+ const result = await this.runConnectionRequest(() => connection.extMethod(method, payload)); -+ return boundedExtensionRecord(result); -+ } - async setSessionModel(sessionId, modelId, controlOverride) { - const control = this.resolveModelControl(sessionId, controlOverride); - if (!control) throw new RequestedModelUnsupportedError(`Cannot set model "${modelId}": the ACP session did not advertise a model config option or legacy session/set_model support.`, "missing-capability"); -- const resolvedModelId = resolveRequestedModelId({ -- requestedModel: modelId, -- models: controlOverride?.availableModels ? { availableModels: controlOverride.availableModels } : void 0, -- agentCommand: this.options.agentCommand -- }); -- return control.kind === "config_option" ? await this.setSessionModelThroughConfig(sessionId, resolvedModelId, control.configId) : await this.setSessionModelThroughLegacyMethod(sessionId, resolvedModelId); -+ return control.kind === "config_option" ? await this.setSessionModelThroughConfig(sessionId, modelId, control.configId) : await this.setSessionModelThroughLegacyMethod(sessionId, modelId); - } - async setSessionModelThroughConfig(sessionId, modelId, configId) { - const connection = this.getConnection(); -@@ -4608,7 +4835,9 @@ var AcpClient = class { - configId, - value: modelId - })); -- this.rememberSessionModels(sessionId, modelStateFromConfigOptions(response.configOptions)); -+ const selected = modelStateFromConfigOptions(response.configOptions); -+ if (selected?.currentModelId !== modelId) throw Object.assign(new Error(`ACP effective model mismatch: requested ${modelId}, received ${selected?.currentModelId ?? "unverified"}`), { code: "ACPX_EFFECTIVE_MODEL_MISMATCH" }); -+ this.rememberSessionModels(sessionId, selected); - return response; - } catch (error) { - return this.throwSessionModelError("session/set_config_option", modelId, error); -@@ -4864,7 +5093,7 @@ var AcpClient = class { - } - selectAuthMethod(methods) { - for (const method of methods) { -- const envCredential = readEnvCredential(method.id); -+ const envCredential = readEnvCredential(method.id, resolveAgentEnvironment(this.options.spawnEnvironment)); - if (envCredential) return { - methodId: method.id, - credential: envCredential, -@@ -4890,7 +5119,7 @@ var AcpClient = class { - } - readAgentSpecificEnvCredential(methodId) { - if (!this.isGrokBuildAcpCommand() || methodId !== "xai.api_key") return; -- const value = process.env.XAI_API_KEY; -+ const value = (resolveAgentEnvironment(this.options.spawnEnvironment)).XAI_API_KEY; - return typeof value === "string" && value.trim().length > 0 ? value : void 0; - } - selectAgentManagedAuthMethod(methodId) { -@@ -4915,9 +5144,9 @@ var AcpClient = class { - await connection.authenticate({ methodId: selected.methodId }); - this.log(`authenticated with method ${selected.methodId} (${selected.source})`); - } -- async handlePermissionRequest(params) { -+ async handlePermissionRequest(params, responseDelivery) { - if (this.cancellingSessionIds.has(params.sessionId)) return cancelledPermissionResponse(); -- const hostResponse = await this.tryHandlePermissionRequestWithHost(params); -+ const hostResponse = await this.tryHandlePermissionRequestWithHost(params, responseDelivery); - if (hostResponse) return hostResponse; - const { response, recorded } = await this.resolvePermissionRequestFromMode(params); - if (!recorded) { -@@ -4926,14 +5155,81 @@ var AcpClient = class { - } - return response; - } -- async handleElicitationRequest(request, requestId, requestSignal) { -+ async handleExtensionRequest(method, params, requestId, requestSignal, responseDelivery) { -+ const active = this.activePrompt; -+ const connection = this.connection; -+ if (!this.options.extensionMethods.includes(method) || !this.options.onExtensionRequest) throw new Error("ACP extension method is not enabled"); -+ if (!active || !connection || this.closing || (params.sessionId !== void 0 && params.sessionId !== active.sessionId)) throw new Error("ACP extension request has no matching active prompt"); -+ if (!(typeof requestId === "string" || typeof requestId === "number" && Number.isFinite(requestId))) throw new Error("ACP extension request id is invalid"); -+ const signal = AbortSignal.any([requestSignal, connection.signal, active.elicitationController.signal]); -+ signal.throwIfAborted(); -+ let onAbort; -+ const aborted = new Promise((_, reject) => { -+ onAbort = () => reject(new Error("ACP extension request was cancelled")); -+ signal.addEventListener("abort", onAbort, { once: true }); -+ }); -+ try { -+ const result = await Promise.race([Promise.resolve().then(() => this.options.onExtensionRequest(method, boundedExtensionRecord(params), { requestId, signal, responseDelivery })), aborted]); -+ signal.throwIfAborted(); -+ if (this.activePrompt !== active || this.connection !== connection) throw new Error("ACP extension request outlived its prompt"); -+ return boundedExtensionRecord(result); -+ } finally { -+ signal.removeEventListener("abort", onAbort); -+ } -+ } -+ handleCursorSubagentNotification(message, owner) { -+ // Keep the SDK's standard union closed. Cursor child transcripts use -+ // their own session IDs and must never be flattened into the parent. -+ const method = "cursor/subagent_update"; -+ if (!this.options.extensionMethods.includes(method) || this.options.clientCapabilities?._meta?.subagents !== true) return false; -+ if (message?.method !== "session/update" || "id" in message) return false; -+ const lifecycle = ["subagent_spawned", "subagent_state_update"].includes(message?.params?.update?.sessionUpdate); -+ const active = this.activePrompt; -+ const connection = this.connection; -+ if (this.cursorSubagentStreamOwner !== owner || this.closing || connection?.signal.aborted) return true; -+ if (!active || !connection || active.elicitationController.signal.aborted) return lifecycle || owner.children.has(message?.params?.sessionId); -+ if (owner.active !== active) { owner.active = active; owner.children.clear(); } -+ if (!lifecycle && message?.params?.sessionId === active.sessionId) return false; -+ try { -+ const params = boundedExtensionRecord(message.params); -+ if (lifecycle) { -+ if (params.sessionId !== active.sessionId && !owner.children.has(params.sessionId)) return true; -+ const child = params.update?.subagentSessionId; -+ if (typeof child !== "string" || !child || child.length > 1000 || child === active.sessionId) return true; -+ if (params.update.sessionUpdate === "subagent_spawned") { -+ if (owner.children.has(child) || owner.children.size >= 256) return true; -+ owner.children.set(child, params.sessionId); -+ } else if (owner.children.get(child) !== params.sessionId) return true; -+ this.options.onExtensionNotification?.(method, { sessionId: active.sessionId, parentSessionId: params.sessionId, update: params.update }); -+ } else if (owner.children.has(params.sessionId)) { -+ this.options.onExtensionNotification?.(method, { sessionId: active.sessionId, parentSessionId: owner.children.get(params.sessionId), childSessionId: params.sessionId, update: params.update }); -+ } -+ } catch { -+ this.log("Cursor child activity notification was rejected"); -+ } -+ return true; -+ } -+ handleExtensionNotification(message) { -+ if (!this.options.extensionMethods.includes(message.method) || !this.options.onExtensionNotification || this.closing) return; -+ const active = this.activePrompt; -+ if (!active || active.elicitationController.signal.aborted) return; -+ try { -+ const params = boundedExtensionRecord(message.params); -+ if (params.sessionId !== void 0 && params.sessionId !== active.sessionId) return; -+ this.options.onExtensionNotification(message.method, params); -+ } catch { -+ this.log("ACP extension notification was rejected"); -+ } -+ } -+ async handleElicitationRequest(request, requestId, requestSignal, responseDelivery) { - const resolved = this.resolveElicitationOwner(request); - if ("response" in resolved) return resolved.response; - const { active, handler } = resolved.owner; - const signal = AbortSignal.any([requestSignal, active.elicitationController.signal]); - const handlerAttempt = Promise.resolve().then(async () => await handler(request, { - requestId, -- signal -+ signal, -+ responseDelivery - })).then((response) => ({ - kind: "response", - response -@@ -4970,7 +5266,7 @@ var AcpClient = class { - isElicitationSessionCancelling(sessionId) { - return this.closing || this.cancellingSessionIds.has(sessionId); - } -- async tryHandlePermissionRequestWithHost(params) { -+ async tryHandlePermissionRequestWithHost(params, responseDelivery) { - if (!this.options.onPermissionRequest) return; - const signal = this.cancellationSignalForSession(params.sessionId); - try { -@@ -4978,7 +5274,7 @@ var AcpClient = class { - sessionId: params.sessionId, - raw: params, - inferredKind: inferToolKind(params) -- }, { signal }); -+ }, { signal, responseDelivery }); - return this.hostPermissionDecisionResponse(params, signal, decision); - } catch (error) { - return this.hostPermissionErrorResponse(params, signal, error); -@@ -5028,6 +5324,12 @@ var AcpClient = class { - attachAgentLifecycleObservers(child) { - child.once("exit", (exitCode, signal) => { - this.recordAgentExit("process_exit", exitCode, signal); -+ this.options.onAgentExit?.({ -+ pid: child.pid ?? this.lastKnownPid, -+ exitCode, -+ signal, -+ exitedAt: isoNow$1() -+ }); - }); - child.once("close", (exitCode, signal) => { - this.recordAgentExit("process_close", exitCode, signal); -@@ -5100,6 +5402,9 @@ var AcpClient = class { - return await this.filesystem.readTextFile(params); - } catch (error) { - this.recordPermissionError(params.sessionId, error); -+ // Keep ENOENT distinct from permission and other filesystem failures. -+ // The ACP peer must receive a resource error before it creates a file. -+ if (error && typeof error === "object" && error.code === "ENOENT") throw RequestError.resourceNotFound(params.path); - throw error; - } - } -@@ -5239,6 +5544,7 @@ function applyConversation(record, conversation) { - record.cumulative_token_usage = conversation.cumulative_token_usage; - record.cumulative_cost = conversation.cumulative_cost; - record.request_token_usage = conversation.request_token_usage; -+ record.cursor_prompt_usage = boundCursorPromptUsage(conversation.cursor_prompt_usage, conversation.messages); - } - //#endregion - //#region src/runtime/engine/session-options.ts -@@ -5701,7 +6007,8 @@ function cloneSessionConversation(conversation) { - updated_at: conversation.updated_at, - cumulative_token_usage: deepClone(conversation.cumulative_token_usage ?? {}), - cumulative_cost: cloneUsageCost(conversation.cumulative_cost), -- request_token_usage: deepClone(conversation.request_token_usage ?? {}) -+ request_token_usage: deepClone(conversation.request_token_usage ?? {}), -+ cursor_prompt_usage: boundCursorPromptUsage(conversation.cursor_prompt_usage, conversation.messages) - }; - } - function cloneUsageCost(cost) { -@@ -5771,10 +6078,20 @@ function recordSessionUpdate(conversation, state, notification, timestamp = isoN - trimConversationForRuntime(conversation); - return acpx; - } -+function parsePaperclipPiReceipt(raw, costField = "cost_usd") { -+ const receipt = asRecord$4(raw); -+ if (receipt?.provenance !== "assistant_message_receipts" && receipt?.provenance !== "assistant_message_and_compaction_receipts") return; -+ const cost = receipt[costField]; -+ if (cost !== void 0 && !isNonNegativeFiniteNumber(cost)) return; -+ return { provenance: receipt.provenance, ...cost !== void 0 ? { cost_usd: cost } : {} }; -+} - function recordPromptResponseUsage(conversation, usage, promptMessageId, timestamp = isoNow()) { - const tokenUsage = sourceToTokenUsage(usage); -- if (!tokenUsage) return false; -- applyTokenUsage(conversation, tokenUsage, promptMessageId); -+ const receipt = parsePaperclipPiReceipt(asRecord$4(asRecord$4(usage)?._meta)?.paperclipPi, "costUsd"); -+ if (!tokenUsage && !receipt) return false; -+ if (tokenUsage) applyTokenUsage(conversation, tokenUsage, promptMessageId); -+ const userId = promptMessageId ?? lastUserMessageId(conversation); -+ if (receipt && userId) conversation.request_token_usage[userId] = { ...conversation.request_token_usage[userId], paperclip_pi: receipt }; - updateConversationTimestamp(conversation, timestamp); - trimConversationForRuntime(conversation); - return true; -@@ -6136,6 +6453,7 @@ async function connectAndLoadSession(options) { - pendingAgentSessionId = loadState.pendingAgentSessionId; - sessionModels = loadState.sessionModels; - const preferenceReplay = await replayFreshSessionPreferences({ -+ reusingLoadedSession, - client, - record, - createdFreshSession, -@@ -6193,14 +6511,16 @@ function logReconnectAttempt(record, storedProcessAlive, shouldReconnect, verbos - if (shouldReconnect) process.stderr.write(`[acpx] saved session pid ${record.pid} is dead; respawning agent and attempting session reconnect\n`); - } - async function replayFreshSessionPreferences(params) { -- if (!params.createdFreshSession) return { -+ // A load acknowledgement can reset the model or config options even when the conversation survives. -+ // Retained connections have no new acknowledgement to replay. -+ if (params.reusingLoadedSession) return { - modelReplay: { replayed: false }, - configReplay: { replayed: false } - }; - let modelReplay = { replayed: false }; - let configReplay = { replayed: false }; - try { -- await replayDesiredMode({ -+ if (params.createdFreshSession) await replayDesiredMode({ - client: params.client, - sessionId: params.sessionId, - desiredModeId: params.desiredModeId, -@@ -6435,6 +6755,27 @@ async function withConnectedSession(options) { - //#region src/runtime/engine/prompt-turn.ts - const SESSION_REPLY_IDLE_MS = 1e3; - const SESSION_REPLY_DRAIN_TIMEOUT_MS = 5e3; -+const TYPED_SESSION_FAILURE_CATEGORIES = /* @__PURE__ */ new Set([ -+ "connection", -+ "access", -+ "limit", -+ "service", -+ "request", -+ "unknown" -+]); -+function typedTerminalSessionFailureCategory(response) { -+ if (response === null || typeof response !== "object" || Array.isArray(response)) return null; -+ const meta = response._meta; -+ if (meta === null || typeof meta !== "object" || Array.isArray(meta)) return null; -+ const jetbrains = meta.jetbrains; -+ if (jetbrains === null || typeof jetbrains !== "object" || Array.isArray(jetbrains)) return null; -+ const air = jetbrains.air; -+ if (air === null || typeof air !== "object" || Array.isArray(air)) return null; -+ if (!Number.isInteger(air.version) || air.version < 1) return null; -+ const failure = air.sessionFailure; -+ if (failure === null || typeof failure !== "object" || Array.isArray(failure) || failure.severity !== "error") return null; -+ return typeof failure.category === "string" && TYPED_SESSION_FAILURE_CATEGORIES.has(failure.category) ? failure.category : "unknown"; -+} - async function runPromptTurn(params) { - try { - const promptPromise = params.client.prompt(params.sessionId, params.prompt, params.onPromptRequestStarted, params.onElicitation); -@@ -6444,7 +6785,23 @@ async function runPromptTurn(params) { - idleMs: SESSION_REPLY_IDLE_MS, - timeoutMs: SESSION_REPLY_DRAIN_TIMEOUT_MS - }).catch(() => {}); -+ // A failed terminal result may still include authoritative billable usage. - recordPromptResponseUsage(params.conversation, response.usage, params.promptMessageId); -+ recordCursorPromptUsage(params.conversation, response, params.requestId, params.promptMessageId); -+ const terminalFailureCategory = typedTerminalSessionFailureCategory(response); -+ if (terminalFailureCategory !== null) { -+ // Pass complete provider text to the diagnostic callback. Consumers redact -+ // before bounding it; truncating here can split and expose a credential. -+ const failure = response._meta.jetbrains.air.sessionFailure; -+ try { -+ params.onTerminalSessionFailure?.({ -+ category: terminalFailureCategory, -+ ...(typeof failure.title === "string" ? { title: failure.title } : {}), -+ ...(typeof failure.details === "string" ? { details: failure.details } : {}) -+ }); -+ } catch {} -+ throw new Error(`ACP agent reported a terminal ${terminalFailureCategory} failure.`); -+ } - return { - stopReason: response.stopReason, - source: "rpc" -diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts -index e8102acb03c4c38830ad5ec22f356125eb0423b7..04c7e3ae0453b352447efb53af17f6e25ee98307 100644 ---- a/dist/runtime.d.ts -+++ b/dist/runtime.d.ts -@@ -1,7 +1,8 @@ - import { _ as SessionRecord, a as AcpElicitationHandler, c as AcpElicitationResponse, f as McpServer$1, h as PermissionPolicy, i as AcpElicitationContext, l as AcpPermissionDecision, m as PermissionMode, n as SystemPromptOption, o as AcpElicitationMode, p as NonInteractivePermissionPolicy, s as AcpElicitationRequest, t as SessionAgentOptions, u as AcpPermissionRequest } from "./session-options-DwRDODlr.js"; - import { a as RequestedModelUnsupportedErrorCode, i as RequestedModelUnsupportedError, n as REQUESTED_MODEL_UNSUPPORTED_ERROR_CODE, o as RequestedModelUnsupportedReason, r as REQUESTED_MODEL_UNSUPPORTED_REASONS, s as isRequestedModelUnsupportedError, t as AcpClient } from "./client-CxNllqui.js"; -+import { ChildProcess, SpawnOptionsWithoutStdio } from "node:child_process"; - import fs from "node:fs"; --import { ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk"; -+import { SessionNotification, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk"; - //#region src/agent-registry.d.ts - declare const DEFAULT_AGENT_NAME = "codex"; - //#endregion -@@ -44,6 +45,7 @@ type AcpRuntimeTurnAttachment = { - data: string; - }; - type AcpRuntimeTurnInput = { -+ onTerminalSessionFailure?: (failure: { category: string; title?: string; details?: string }) => void; - handle: AcpRuntimeHandle; - text: string; - attachments?: AcpRuntimeTurnAttachment[]; -@@ -141,6 +143,11 @@ type AcpTextDeltaOriginMeta = { - kind?: string; - source?: string; - }; -+type AcpRuntimePlanEntry = { -+ content: string; -+ priority?: string; -+ status: "pending" | "in_progress" | "completed"; -+}; - type AcpRuntimeEvent = { - type: "text_delta"; - text: string; -@@ -186,6 +193,15 @@ type AcpRuntimeEvent = { - * non-null `input` schema. - */ - availableCommands?: AcpRuntimeAvailableCommand[]; -+ /** -+ * Paperclip patch: the ACP update's `_meta`, forwarded by reference and -+ * unvalidated. Producer-controlled; consumers must bound and allowlist it. -+ */ -+ _meta?: Record; -+} | { -+ type: "plan"; -+ tag: "plan"; -+ entries: AcpRuntimePlanEntry[]; - } | { - type: "tool_call"; - text: string; -@@ -198,6 +214,11 @@ type AcpRuntimeEvent = { - rawInput?: unknown; - rawOutput?: unknown; - content?: ToolCallContent[]; -+ /** -+ * Paperclip patch: the ACP update's `_meta`, forwarded by reference and -+ * unvalidated. Producer-controlled; consumers must bound and allowlist it. -+ */ -+ _meta?: Record; - } | - /** - * Compatibility terminal event emitted by runTurn(...). startTurn(...).events -@@ -312,7 +333,51 @@ type AcpRuntimeOptions = { - elicitationModes?: readonly AcpElicitationMode[]; - onPermissionRequest?: (req: AcpPermissionRequest, ctx: { - signal: AbortSignal; -+ /** Resolves after this exact response is written to the current provider pipe. */ -+ responseDelivery?: Promise; - }) => Promise; -+ /** Ephemeral initialize capabilities; mandatory base capabilities take precedence. */ -+ clientCapabilities?: Record; -+ /** Closed list of inbound extension request and notification method names. */ -+ extensionMethods?: readonly string[]; -+ /** Bound to the current connection and active prompt; never persisted. */ -+ onExtensionRequest?: (method: string, params: Record, context: { -+ requestId: string | number; -+ signal: AbortSignal; -+ responseDelivery?: Promise; -+ }) => Promise>; -+ onExtensionNotification?: (method: string, params: Record) => void; -+ /** Ephemeral allowlisted environment evaluated immediately before child spawn. */ -+ spawnEnvironment?: () => Record; -+ /** Host-only spawn cwd; does not change the cwd advertised in session/new. */ -+ spawnCwd?: string; -+ /** Host-owned verified executable launch. */ -+ spawnAgent?: (input: { -+ command: string; -+ args: readonly string[]; -+ options: SpawnOptionsWithoutStdio; -+ }) => ChildProcess; -+ onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise | void; -+ onAgentStderr?: (chunk: string) => void; -+ onAgentExit?: (meta: { -+ pid?: number; -+ exitCode: number | null; -+ signal: NodeJS.Signals | null; -+ exitedAt: string; -+ }) => void; -+ /** Synchronous admission guard. Exceptions fail the connection, never an observer. */ -+ protocolGuardFactory?: () => (direction: "inbound" | "outbound", message: unknown) => void; -+ onAcpMessage?: (direction: "inbound" | "outbound", message: unknown) => void; -+ /** Ephemeral full ACP notification callback; never written to session records. */ -+ onSessionNotification?: (notification: SessionNotification) => void; -+ /** Ephemeral normalized ACP client filesystem/terminal operation callback. */ -+ onClientOperation?: (operation: { -+ method: string; -+ status: string; -+ summary: string; -+ details?: string; -+ timestamp: string; -+ }) => void; - }; - type AcpFileSessionStoreOptions = { - stateDir: string; -@@ -369,6 +434,7 @@ declare class AcpRuntimeManager { - private createAndSaveRuntimeRecord; - private retainInitializedSessionOwner; - startTurn(input: { -+ onTerminalSessionFailure?: (failure: { category: string; title?: string; details?: string }) => void; - handle: AcpRuntimeHandle; - text: string; - attachments?: AcpRuntimeTurnAttachment[]; -@@ -414,6 +480,7 @@ declare class AcpRuntimeManager { - private cleanupRuntimeTurn; - private finalizeRuntimeTurnRecord; - runTurn(input: { -+ onTerminalSessionFailure?: (failure: { category: string; title?: string; details?: string }) => void; - handle: AcpRuntimeHandle; - text: string; - attachments?: AcpRuntimeTurnAttachment[]; -diff --git a/dist/runtime.js b/dist/runtime.js -index a1f4a70a003792c6eacf68b6b038f37bfec1db53..f8603a26e626253b454812865b99b0d7aa4495f6 100644 ---- a/dist/runtime.js -+++ b/dist/runtime.js -@@ -153,7 +153,7 @@ function resolveTextChunk(params) { - const contentType = asTrimmedString(contentRaw.type); - if (contentType && contentType !== "text") return null; - const text = asString(contentRaw.text); -- if (text && text.length > 0) return { -+ if (typeof text === "string" && (text.length > 0 || origin.messageId)) return { - type: "text_delta", - text, - stream: params.stream, -@@ -162,7 +162,7 @@ function resolveTextChunk(params) { - }; - } - const text = asString(params.payload.text); -- if (!text || text.length === 0) return null; -+ if (typeof text !== "string" || (text.length === 0 && !origin.messageId)) return null; - return { - type: "text_delta", - text, -@@ -321,6 +321,7 @@ function assignForwardedToolPayload(event, payload) { - if (Object.prototype.hasOwnProperty.call(payload, "rawInput")) event.rawInput = payload.rawInput; - if (Object.prototype.hasOwnProperty.call(payload, "rawOutput")) event.rawOutput = payload.rawOutput; - if (shouldForwardArray(payload.content)) event.content = payload.content; -+ if (isRecord(payload._meta)) event._meta = payload._meta; - } - function parsePromptEventLine(line) { - const trimmed = line.trim(); -@@ -371,7 +372,7 @@ const PROMPT_EVENT_PARSERS = { - current_mode_update: (payload) => statusUpdateEvent("current_mode_update", payload), - config_option_update: (payload) => statusUpdateEvent("config_option_update", payload), - session_info_update: (payload) => statusUpdateEvent("session_info_update", payload), -- plan: (payload) => statusUpdateEvent("plan", payload), -+ plan: planUpdateEvent, - client_operation: clientOperationEvent, - update: updateStatusEvent, - done: () => null, -@@ -388,11 +389,12 @@ function usageUpdateEvent(payload) { - used, - size, - cost: normalizeUsageCost(payload.cost), -- breakdown: normalizeUsageBreakdown(meta?.usage) -+ breakdown: normalizeUsageBreakdown(meta?.usage), -+ meta - }); - } - function buildUsageUpdateEvent(parts) { -- const { used, size, cost, breakdown } = parts; -+ const { used, size, cost, breakdown, meta } = parts; - return { - type: "status", - text: used != null && size != null ? `usage updated: ${used}/${size}` : "usage updated", -@@ -400,7 +402,8 @@ function buildUsageUpdateEvent(parts) { - ...used != null ? { used } : {}, - ...size != null ? { size } : {}, - ...cost ? { cost } : {}, -- ...breakdown ? { breakdown } : {} -+ ...breakdown ? { breakdown } : {}, -+ ...meta ? { _meta: meta } : {} - }; - } - function availableCommandsUpdateEvent(payload) { -@@ -424,6 +427,34 @@ function availableCommandsUpdateEvent(payload) { - availableCommands - }; - } -+function persistedGoalCapability(goal) { -+ if (!isRecord(goal) || goal.version !== 1 || goal.controlMethod !== "_session/goal" || !Array.isArray(goal.actions)) return; -+ const actions = goal.actions.filter((action) => ["set", "pause", "resume", "clear"].includes(action)); -+ if (!actions.includes("set") || !actions.includes("clear")) return; -+ return { version: 1, control_method: goal.controlMethod, actions }; -+} -+function restoredGoalCapability(goal) { -+ if (!isRecord(goal)) return; -+ const canonical = persistedGoalCapability({ ...goal, controlMethod: goal.control_method ?? goal.controlMethod }); -+ if (!canonical) return; -+ return { version: canonical.version, controlMethod: canonical.control_method, actions: canonical.actions }; -+} -+ -+ -+function planUpdateEvent(payload) { -+ const raw = Array.isArray(payload.entries) ? payload.entries : []; -+ const entries = []; -+ for (const entry of raw) { -+ if (!isRecord(entry)) continue; -+ const content = asTrimmedString(entry.content); -+ if (!content) continue; -+ const status = asTrimmedString(entry.status); -+ if (status !== "pending" && status !== "in_progress" && status !== "completed") continue; -+ const priority = asTrimmedString(entry.priority); -+ entries.push({ content, status, ...priority ? { priority } : {} }); -+ } -+ return { type: "plan", tag: "plan", entries }; -+} - function normalizeUsageCost(value) { - if (!isRecord(value)) return; - const amount = asOptionalFiniteNumber(value.amount); -@@ -474,7 +505,8 @@ function statusUpdateEvent(tag, payload) { - return { - type: "status", - text, -- tag -+ tag, -+ ...isRecord(payload._meta) ? { _meta: payload._meta } : {} - }; - } - function clientOperationEvent(payload, tag) { -@@ -812,7 +844,22 @@ var AcpRuntimeManager = class { - this.deps = deps; - } - createClient(options) { -- return this.deps.clientFactory?.(options) ?? new AcpClient(options); -+ const patchedOptions = { -+ ...options, -+ spawnCwd: this.options.spawnCwd, -+ spawnEnvironment: this.options.spawnEnvironment, -+ spawnAgent: this.options.spawnAgent, -+ onAgentSpawn: this.options.onAgentSpawn, -+ onAgentStderr: this.options.onAgentStderr, -+ onAgentExit: this.options.onAgentExit, -+ onAcpMessage: this.options.onAcpMessage, -+ clientCapabilities: this.options.clientCapabilities, -+ protocolGuardFactory: this.options.protocolGuardFactory, -+ extensionMethods: this.options.extensionMethods, -+ onExtensionRequest: this.options.onExtensionRequest, -+ onExtensionNotification: this.options.onExtensionNotification -+ }; -+ return this.deps.clientFactory?.(patchedOptions) ?? new AcpClient(patchedOptions); - } - createSessionOwner(input) { - const owner = { -@@ -821,12 +868,16 @@ var AcpRuntimeManager = class { - pendingSessionUpdates: [] - }; - input.client.setEventHandlers({ -+ onAcpMessage: this.options.onAcpMessage, - onSessionUpdate: (notification) => this.routeOwnedSessionUpdate(owner, notification), - onClientOperation: (operation) => this.routeOwnedClientOperation(owner, operation) - }); - return owner; - } - routeOwnedSessionUpdate(owner, notification) { -+ try { -+ this.options.onSessionNotification?.(notification); -+ } catch {} - const active = owner.activeTurn; - if (active) { - const { task, turn } = active; -@@ -863,6 +914,9 @@ var AcpRuntimeManager = class { - projection.checkpoint.request(); - } - routeOwnedClientOperation(owner, operation) { -+ try { -+ this.options.onClientOperation?.(operation); -+ } catch {} - const active = owner.activeTurn; - if (!active) return; - const { task, turn } = active; -@@ -1008,6 +1062,8 @@ var AcpRuntimeManager = class { - record.closedAt = void 0; - record.protocolVersion = owner.client.initializeResult?.protocolVersion; - record.agentCapabilities = owner.client.initializeResult?.agentCapabilities; -+ record.agentGoalCapability = persistedGoalCapability(owner.client.initializeResult?._meta?.goal); -+ this.options.onAgentInitialize?.(owner.client.initializeResult); - applyLifecycleSnapshotToRecord(record, owner.client.getAgentLifecycleSnapshot()); - } - async finishBufferedOwnerControl(owner, record) { -@@ -1086,6 +1142,11 @@ var AcpRuntimeManager = class { - record.closed = false; - record.closedAt = void 0; - this.closingActiveRecords.delete(record.acpxRecordId); -+ this.options.onAgentInitialize?.({ -+ protocolVersion: record.protocolVersion, -+ agentCapabilities: record.agentCapabilities, -+ _meta: record.agentGoalCapability ? { goal: restoredGoalCapability(record.agentGoalCapability) } : void 0 -+ }); - await this.options.sessionStore.save(record); - return record; - } -@@ -1149,6 +1210,8 @@ var AcpRuntimeManager = class { - this.closingActiveRecords.delete(record.acpxRecordId); - record.protocolVersion = client.initializeResult?.protocolVersion; - record.agentCapabilities = client.initializeResult?.agentCapabilities; -+ record.agentGoalCapability = persistedGoalCapability(client.initializeResult?._meta?.goal); -+ this.options.onAgentInitialize?.(client.initializeResult); - applyConfigOptionsToRecord(record, session.sessionResult); - const modelApplication = await applyRequestedModelIfAdvertised({ - client, -@@ -1301,9 +1364,11 @@ var AcpRuntimeManager = class { - client: turn.client, - sessionId, - prompt: task.promptInput, -+ onTerminalSessionFailure: task.input.onTerminalSessionFailure, - timeoutMs: task.input.timeoutMs ?? this.options.timeoutMs, - conversation: turn.conversation, - promptMessageId: turn.promptMessageId, -+ requestId: task.input.requestId, - onPromptRequestStarted: () => task.promptStarted.resolve(), - onElicitation: task.input.onElicitation - }); -@@ -1469,6 +1534,10 @@ var AcpRuntimeManager = class { - setSessionConfigOption: async (configId, value) => { - return (await task.state.activeController.setResolvedSessionConfigOption(configId, value)).response; - }, -+ requestExtension: async (method, params) => { -+ await this.waitForRuntimeControlSession(task, turn); -+ return await turn.client.requestExtension(method, params); -+ }, - setResolvedSessionConfigOption: async (configId, value) => await this.setRuntimeResolvedSessionConfigOption(task, turn, configId, value) - }; - } -@@ -1575,6 +1644,8 @@ var AcpRuntimeManager = class { - reconcileAgentSessionId(turn.record, turn.record.agentSessionId); - turn.record.protocolVersion = turn.client.initializeResult?.protocolVersion; - turn.record.agentCapabilities = turn.client.initializeResult?.agentCapabilities; -+ turn.record.agentGoalCapability = persistedGoalCapability(turn.client.initializeResult?._meta?.goal); -+ this.options.onAgentInitialize?.(turn.client.initializeResult); - turn.record.acpx = turn.acpxState; - applyConversation(turn.record, turn.conversation); - applyLifecycleSnapshotToRecord(turn.record, turn.client.getAgentLifecycleSnapshot()); -@@ -1707,6 +1778,17 @@ var AcpRuntimeManager = class { - }); - await this.options.sessionStore.save(result.record); - } -+ async requestExtension(input) { -+ const recordId = input.handle.acpxRecordId ?? input.handle.sessionKey; -+ return await this.withManagerLock(this.runtimeOperationLocks, recordId, async () => { -+ const record = await this.requireRecord(recordId); -+ const controller = this.activeControllers.get(record.acpxRecordId); -+ if (controller) return await controller.requestExtension(input.method, input.params); -+ return (await this.withRuntimeControlSession(record, input.sessionMode ?? "persistent", async ({ client }) => { -+ return await client.requestExtension(input.method, input.params); -+ })).value; -+ }); -+ } - async cancel(handle) { - await this.activeControllers.get(handle.acpxRecordId ?? handle.sessionKey)?.requestCancelActivePrompt(); - } -@@ -2055,6 +2137,7 @@ var AcpxRuntime = class { - const turnPromise = this.getManager().then((manager) => manager.startTurn({ - handle, - text: input.text, -+ onTerminalSessionFailure: input.onTerminalSessionFailure, - attachments: input.attachments, - mode: input.mode, - sessionMode: state.mode, -@@ -2087,6 +2170,7 @@ var AcpxRuntime = class { - yield* (await this.getManager()).runTurn({ - handle, - text: input.text, -+ onTerminalSessionFailure: input.onTerminalSessionFailure, - attachments: input.attachments, - mode: input.mode, - sessionMode: state.mode, -@@ -2119,6 +2203,14 @@ var AcpxRuntime = class { - const { handle, state } = this.resolveManagerHandle(input.handle); - await (await this.getManager()).setConfigOption(handle, input.key, input.value, state.mode); - } -+ async requestExtension(input) { -+ const { handle, state } = this.resolveManagerHandle(input.handle); -+ return await (await this.getManager()).requestExtension({ -+ ...input, -+ handle, -+ sessionMode: input.sessionMode ?? state.mode -+ }); -+ } - async cancel(input) { - const { handle } = this.resolveManagerHandle(input.handle); - await (await this.getManager()).cancel(handle); -diff --git a/dist/session-options-DwRDODlr.d.ts b/dist/session-options-DwRDODlr.d.ts -index c3da1645235bbea22de3f8484149051cd7dca56b..40ae0565394aaa96e0315a2cf3c943c500c77560 100644 ---- a/dist/session-options-DwRDODlr.d.ts -+++ b/dist/session-options-DwRDODlr.d.ts -@@ -1,4 +1,5 @@ - import { AgentCapabilities, AnyMessage, ContentBlock, CreateElicitationRequest, ElicitationContentValue, JsonRpcId, McpServer, McpServer as McpServer$1, RequestPermissionRequest, SessionConfigOption, SessionNotification, SetSessionConfigOptionResponse, ToolKind } from "@agentclientprotocol/sdk"; -+import { ChildProcess, SpawnOptionsWithoutStdio } from "node:child_process"; - //#region src/prompt-content.d.ts - type PromptInput = ContentBlock[]; - //#endregion -@@ -27,6 +28,8 @@ type AcpElicitationContext = { - requestId: JsonRpcId; - /** Aborts with the request itself or its owning prompt turn/session. */ - signal: AbortSignal; -+ /** Await only after returning the selected response from this callback. */ -+ responseDelivery?: Promise; - }; - type AcpElicitationResponseMeta = { - _meta?: Record | null; -@@ -116,6 +119,37 @@ type AcpClientOptions = { - }; - env?: Record; - }; -+ /** Ephemeral initialize capabilities; mandatory base capabilities take precedence. */ -+ clientCapabilities?: Record; -+ /** Closed list of inbound extension request and notification method names. */ -+ extensionMethods?: readonly string[]; -+ /** Bound to the current connection and active prompt; never persisted. */ -+ onExtensionRequest?: (method: string, params: Record, context: { -+ requestId: string | number; -+ signal: AbortSignal; -+ responseDelivery?: Promise; -+ }) => Promise>; -+ onExtensionNotification?: (method: string, params: Record) => void; -+ /** Ephemeral child environment factory; its return value is never persisted. */ -+ spawnEnvironment?: () => Record; -+ /** Host-only child cwd, separate from the cwd advertised to ACP. */ -+ spawnCwd?: string; -+ /** Host-owned verified executable launch. */ -+ spawnAgent?: (input: { -+ command: string; -+ args: readonly string[]; -+ options: SpawnOptionsWithoutStdio; -+ }) => ChildProcess; -+ onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise | void; -+ onAgentStderr?: (chunk: string) => void; -+ onAgentExit?: (meta: { -+ pid?: number; -+ exitCode: number | null; -+ signal: NodeJS.Signals | null; -+ exitedAt: string; -+ }) => void; -+ /** Synchronous admission guard. Exceptions fail the connection, never an observer. */ -+ protocolGuardFactory?: () => (direction: "inbound" | "outbound", message: unknown) => void; - onAcpMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void; - onAcpOutputMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void; - onSessionUpdate?: (notification: SessionNotification) => void; -@@ -123,6 +157,8 @@ type AcpClientOptions = { - onPermissionEscalation?: (event: PermissionEscalationEvent) => void; - onPermissionRequest?: (req: AcpPermissionRequest, ctx: { - signal: AbortSignal; -+ /** Resolves after this exact response is written to the current provider pipe. */ -+ responseDelivery?: Promise; - }) => Promise; - }; - declare const SESSION_RECORD_SCHEMA: "acpx.session.v1"; -@@ -263,12 +299,16 @@ type SessionRecord = { - lastAgentDisconnectReason?: string; - protocolVersion?: number; - agentCapabilities?: AgentCapabilities; -+ agentGoalCapability?: Record; - title?: string | null; - messages: SessionMessage[]; - updated_at: string; - cumulative_token_usage: SessionTokenUsage; - cumulative_cost?: SessionUsageCost; -- request_token_usage: Record; -+ request_token_usage: Record; -+ cursor_prompt_usage?: { request_id: string; prompt_message_id: string; receipt: Record }; - acpx?: SessionAcpxState; - importedFrom?: SessionImportedFrom; - }; diff --git a/patches/acpx@0.22.0.patch b/patches/acpx@0.22.0.patch new file mode 100644 index 00000000000..b03bf6e60ed --- /dev/null +++ b/patches/acpx@0.22.0.patch @@ -0,0 +1,484 @@ +diff --git a/dist/queue-owner-runtime-ucHvXJ33.js b/dist/queue-owner-runtime-ucHvXJ33.js +index dc63642..39e5ca6 100644 +--- a/dist/queue-owner-runtime-ucHvXJ33.js ++++ b/dist/queue-owner-runtime-ucHvXJ33.js +@@ -1869,7 +1869,7 @@ function observeAcpStream(base, options) { + return options.suppressReplaySessionUpdates() && isSessionUpdateNotification(message); + }; + const observeInbound = (message) => { +- const requestId = elicitationRequestId(message); ++ const requestId = elicitationRequestId(message) ?? options.extensionRequestId?.(message); + if (requestId !== void 0) { + elicitationRequestIds.add(requestId); + return; +@@ -1884,16 +1884,21 @@ function observeAcpStream(base, options) { + const { value, done } = await reader.read(); + if (done) break; + if (!value) continue; ++ options.enforceProtocol?.("inbound", value); + observeInbound(value); ++ if (options.routeInbound?.(value)) continue; + if (isExtensionNotification(value)) continue; + controller.enqueue(value); + } ++ controller.close(); ++ } catch (error) { ++ controller.error(error); + } finally { + reader.releaseLock(); +- controller.close(); + } + } }), + writable: new WritableStream({ async write(message) { ++ options.enforceProtocol?.("outbound", message); + const promptOwner = promptRequestOwner(message); + const activePrompt = promptOwner ? options.bindPromptOwner(promptOwner) : void 0; + const id = responseId(message); +@@ -2572,6 +2577,32 @@ function childProcessIsRunning(agent) { + function cancelledPermissionResponse() { + return { outcome: { outcome: "cancelled" } }; + } ++function boundedExtensionRecord(value) { ++ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new TypeError("ACP extension payload must be an object"); ++ const prototype = Object.getPrototypeOf(value); ++ if (prototype !== Object.prototype && prototype !== null) throw new TypeError("ACP extension payload must be a plain object"); ++ const serialized = JSON.stringify(value); ++ if (Buffer.byteLength(serialized) > 256 * 1024) throw new TypeError("ACP extension payload exceeds its bounded size"); ++ return JSON.parse(serialized); ++} ++function closedExtensionMethods(value) { ++ if (value === void 0) return Object.freeze([]); ++ if (!Array.isArray(value) || value.length > 64) throw new TypeError("ACP extension methods must be a bounded allowlist"); ++ for (const method of value) if (typeof method !== "string" || !/^[A-Za-z_][A-Za-z0-9._/-]{1,255}$/.test(method) || !method.includes("/") || /^(?:session|fs|terminal|elicitation|notifications)\//.test(method)) throw new TypeError("ACP extension method cannot override a standard method"); ++ return Object.freeze([...new Set(value)]); ++} ++function mergeRuntimeClientCapabilities(base, extra) { ++ if (extra === void 0) return base; ++ const additional = boundedExtensionRecord(extra); ++ return { ++ ...additional, ++ ...base, ++ _meta: { ++ ...additional._meta, ++ ...base._meta ++ } ++ }; ++} + var AcpClient = class { + options; + connection; +@@ -2615,7 +2646,9 @@ var AcpClient = class { + agentProcessEnvBlock: snapshotEnvNames("agentProcessEnvBlock", options.agentProcessEnvBlock), + authPolicy: options.authPolicy ?? "skip", + permissionPolicy: snapshotPermissionPolicy(options.permissionPolicy), +- elicitationModes: normalizeElicitationModes(options.elicitationModes) ++ elicitationModes: normalizeElicitationModes(options.elicitationModes), ++ extensionMethods: closedExtensionMethods(options.extensionMethods), ++ clientCapabilities: options.clientCapabilities === void 0 ? void 0 : boundedExtensionRecord(options.clientCapabilities) + }; + this.eventHandlers = { + onAcpMessage: this.options.onAcpMessage, +@@ -2764,12 +2797,12 @@ var AcpClient = class { + this.rejectPendingConnectionRequests(error); + connection?.close(error); + })); +- const capabilities = resolveClientCapabilities({ ++ const capabilities = mergeRuntimeClientCapabilities(resolveClientCapabilities({ + devinAcp: launch.devinAcp, + fs: this.options.fs !== false, + terminal: this.options.terminal !== false, + elicitationModes: this.options.elicitationModes +- }); ++ }), this.options.clientCapabilities); + connection = this.createConnection(stream, launch, capabilities); + const onConnectionClose = () => { + this.handleAgentDisconnect(child, "connection_close", child.exitCode ?? null, child.signalCode ?? null); +@@ -2876,10 +2909,15 @@ var AcpClient = class { + assertControlAuthority(authority); + let spawnedChild; + try { +- spawnedChild = spawn(launch.command, [...launch.args], { ++ const spawnOptions = { + ...plan.spawnOptions, + windowsVerbatimArguments: prepared.windowsVerbatimArguments +- }); ++ }; ++ spawnedChild = this.options.spawnAgent ? this.options.spawnAgent({ ++ command: launch.command, ++ args: [...launch.args], ++ options: spawnOptions ++ }) : spawn(launch.command, [...launch.args], spawnOptions); + await waitForSpawn(spawnedChild); + } catch (error) { + const spawnError = new AgentSpawnError(this.options.agentCommand, error, { +@@ -3004,6 +3042,9 @@ var AcpClient = class { + }).onRequest(methods.client.elicitation.create, async ({ params, requestId, signal }) => { + return await this.handleElicitationRequest(params, requestId, signal); + }); ++ if (this.options.onExtensionRequest) for (const method of this.options.extensionMethods) app.onRequest(method, boundedExtensionRecord, async ({ params, requestId, signal }) => { ++ return await this.handleExtensionRequest(method, params, requestId, signal); ++ }); + if (capabilities.fs?.readTextFile) app.onRequest(methods.client.fs.readTextFile, async ({ params, signal }) => { + return await this.handleReadTextFile(params, signal); + }); +@@ -3061,7 +3102,37 @@ var AcpClient = class { + throw normalizedError; + } + createTappedStream(base) { ++ // Policy admission is distinct from best-effort observation. Each new ++ // stdio connection gets fresh authority and a violation tears it down. ++ const protocolGuard = this.options.protocolGuardFactory?.(); ++ const enforceProtocol = (direction, message) => { ++ try { ++ protocolGuard?.(direction, message); ++ } catch (error) { ++ this.rejectPendingConnectionRequests(error); ++ this.activePrompt?.elicitationController.abort(error); ++ try { ++ this.agent?.kill("SIGTERM"); ++ } catch {} ++ throw error; ++ } ++ }; ++ const cursorSubagentOwner = { ++ active: null, ++ children: /* @__PURE__ */ new Map() ++ }; ++ this.cursorSubagentStreamOwner = cursorSubagentOwner; + return observeAcpStream(base, { ++ enforceProtocol, ++ extensionRequestId: (message) => "id" in message && this.options.extensionMethods.includes(message.method) ? message.id : void 0, ++ routeInbound: (message) => { ++ if (this.handleCursorSubagentNotification(message, cursorSubagentOwner)) return true; ++ if (isExtensionNotification(message)) { ++ this.handleExtensionNotification(message); ++ return true; ++ } ++ return false; ++ }, + onMessage: (direction, message) => { + this.eventHandlers.onAcpOutputMessage?.(direction, message); + this.eventHandlers.onAcpMessage?.(direction, message); +@@ -3278,6 +3349,13 @@ var AcpClient = class { + }); + return resolveRequestedModelId(params); + } ++ async requestExtension(method, params, authority) { ++ closedExtensionMethods([method]); ++ const payload = boundedExtensionRecord(params); ++ const connection = this.getConnection(); ++ const result = await this.runConnectionRequest(() => connection.agent.request(method, payload), authority); ++ return boundedExtensionRecord(result); ++ } + async setSessionModelThroughConfig(sessionId, modelId, configId, authority) { + const connection = this.getConnection(); + return normalizeConfigOptionAcknowledgement(await this.runConnectionRequest(() => connection.agent.request(methods.agent.session.setConfigOption, { +@@ -3649,6 +3727,89 @@ var AcpClient = class { + hasPermissionOwner(sessionId) { + return !this.pendingPromptOwners.some((owner) => owner.permissionHandler !== void 0) || this.pendingPromptOwners.some((owner) => owner.sessionId === sessionId); + } ++ async handleExtensionRequest(method, params, requestId, requestSignal) { ++ const active = this.activePrompt; ++ const connection = this.connection; ++ if (!this.options.extensionMethods.includes(method) || !this.options.onExtensionRequest) throw new Error("ACP extension method is not enabled"); ++ if (!active || !connection || this.closing || params.sessionId !== void 0 && params.sessionId !== active.sessionId) throw new Error("ACP extension request has no matching active prompt"); ++ if (!(typeof requestId === "string" || typeof requestId === "number" && Number.isFinite(requestId))) throw new Error("ACP extension request id is invalid"); ++ const signal = AbortSignal.any([ ++ requestSignal, ++ connection.signal, ++ active.elicitationController.signal ++ ]); ++ signal.throwIfAborted(); ++ let onAbort; ++ const aborted = new Promise((_, reject) => { ++ onAbort = () => reject(/* @__PURE__ */ new Error("ACP extension request was cancelled")); ++ signal.addEventListener("abort", onAbort, { once: true }); ++ }); ++ try { ++ const result = await Promise.race([Promise.resolve().then(() => this.options.onExtensionRequest(method, boundedExtensionRecord(params), { ++ requestId, ++ signal ++ })), aborted]); ++ signal.throwIfAborted(); ++ if (this.activePrompt !== active || this.connection !== connection) throw new Error("ACP extension request outlived its prompt"); ++ return boundedExtensionRecord(result); ++ } finally { ++ signal.removeEventListener("abort", onAbort); ++ } ++ } ++ handleCursorSubagentNotification(message, owner) { ++ // Keep the SDK's standard union closed. Cursor child transcripts use ++ // their own session IDs and must never be flattened into the parent. ++ const method = "cursor/subagent_update"; ++ if (!this.options.extensionMethods.includes(method) || this.options.clientCapabilities?._meta?.subagents !== true) return false; ++ if (message?.method !== "session/update" || "id" in message) return false; ++ const lifecycle = ["subagent_spawned", "subagent_state_update"].includes(message?.params?.update?.sessionUpdate); ++ const active = this.activePrompt; ++ const connection = this.connection; ++ if (this.cursorSubagentStreamOwner !== owner || this.closing || connection?.signal.aborted) return true; ++ if (!active || !connection || active.elicitationController.signal.aborted) return lifecycle || owner.children.has(message?.params?.sessionId); ++ if (owner.active !== active) { ++ owner.active = active; ++ owner.children.clear(); ++ } ++ if (!lifecycle && message?.params?.sessionId === active.sessionId) return false; ++ try { ++ const params = boundedExtensionRecord(message.params); ++ if (lifecycle) { ++ if (params.sessionId !== active.sessionId && !owner.children.has(params.sessionId)) return true; ++ const child = params.update?.subagentSessionId; ++ if (typeof child !== "string" || !child || child.length > 1e3 || child === active.sessionId) return true; ++ if (params.update.sessionUpdate === "subagent_spawned") { ++ if (owner.children.has(child) || owner.children.size >= 256) return true; ++ owner.children.set(child, params.sessionId); ++ } else if (owner.children.get(child) !== params.sessionId) return true; ++ this.options.onExtensionNotification?.(method, { ++ sessionId: active.sessionId, ++ parentSessionId: params.sessionId, ++ update: params.update ++ }); ++ } else if (owner.children.has(params.sessionId)) this.options.onExtensionNotification?.(method, { ++ sessionId: active.sessionId, ++ parentSessionId: owner.children.get(params.sessionId), ++ childSessionId: params.sessionId, ++ update: params.update ++ }); ++ } catch { ++ this.log("Cursor child activity notification was rejected"); ++ } ++ return true; ++ } ++ handleExtensionNotification(message) { ++ if (!this.options.extensionMethods.includes(message.method) || !this.options.onExtensionNotification || this.closing) return; ++ const active = this.activePrompt; ++ if (!active || active.elicitationController.signal.aborted) return; ++ try { ++ const params = boundedExtensionRecord(message.params); ++ if (params.sessionId !== void 0 && params.sessionId !== active.sessionId) return; ++ this.options.onExtensionNotification(message.method, params); ++ } catch { ++ this.log("ACP extension notification was rejected"); ++ } ++ } + async handleElicitationRequest(request, requestId, requestSignal) { + const resolved = this.resolveElicitationOwner(request); + if ("response" in resolved) return resolved.response; +diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts +index 861fc88..da53821 100644 +--- a/dist/runtime.d.ts ++++ b/dist/runtime.d.ts +@@ -3,6 +3,7 @@ import { C as PermissionMode, D as PromptInput, E as SessionRecord, S as NonInte + import { n as AcpControlAuthority, t as AcpClient } from "./client-BqZW_W14.js"; + import { z } from "zod"; + import { SetSessionConfigOptionResponse, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk"; ++import { ChildProcess, SpawnOptionsWithoutStdio } from "node:child_process"; + export declare const REQUESTED_MODEL_UNSUPPORTED_ERROR_CODE: "ACP_MODEL_UNSUPPORTED"; + type RequestedModelUnsupportedErrorCode = typeof REQUESTED_MODEL_UNSUPPORTED_ERROR_CODE; + export declare const REQUESTED_MODEL_UNSUPPORTED_REASONS: readonly ["missing-capability", "unadvertised-model"]; +@@ -267,6 +268,11 @@ type AcpRuntimeEvent = { + * Replace the displayed plan when present, including clearing it for []. + */ + entries?: AcpRuntimePlanEntry[]; ++ /** ++ * Paperclip patch: the ACP update's `_meta`, forwarded unvalidated. ++ * Producer-controlled; consumers must bound and allowlist it. ++ */ ++ _meta?: Record; + } | { + type: "tool_call"; + text: string; +@@ -279,6 +285,11 @@ type AcpRuntimeEvent = { + rawInput?: unknown; + rawOutput?: unknown; + content?: ToolCallContent[]; ++ /** ++ * Paperclip patch: the ACP update's `_meta`, forwarded unvalidated. ++ * Producer-controlled; consumers must bound and allowlist it. ++ */ ++ _meta?: Record; + } | + /** + * Compatibility terminal event emitted by runTurn(...). startTurn(...).events +@@ -472,6 +483,24 @@ type AcpRuntimeOptions = { + * sandbox for the agent's own process access. The setting is not persisted. + */ + terminal?: boolean; ++ /** Ephemeral initialize capabilities; mandatory base capabilities take precedence. */ ++ clientCapabilities?: Record; ++ /** Closed list of inbound extension request and notification method names. */ ++ extensionMethods?: readonly string[]; ++ /** Bound to the current connection and active prompt; never persisted. */ ++ onExtensionRequest?: (method: string, params: Record, context: { ++ requestId: string | number; ++ signal: AbortSignal; ++ }) => Promise>; ++ onExtensionNotification?: (method: string, params: Record) => void; ++ /** Host-owned verified executable launch. */ ++ spawnAgent?: (input: { ++ command: string; ++ args: readonly string[]; ++ options: SpawnOptionsWithoutStdio; ++ }) => ChildProcess; ++ /** Synchronous admission guard. Exceptions fail the connection, never an observer. */ ++ protocolGuardFactory?: () => (direction: "inbound" | "outbound", message: unknown) => void; + }; + type AcpFileSessionStoreOptions = { + stateDir: string; +diff --git a/dist/runtime.js b/dist/runtime.js +index 006b80c..1b08b54 100644 +--- a/dist/runtime.js ++++ b/dist/runtime.js +@@ -192,7 +192,7 @@ function resolveTextChunk(params) { + const contentType = asTrimmedString(contentRaw.type); + if (contentType && contentType !== "text") return null; + const text = asString(contentRaw.text); +- if (text && text.length > 0) return { ++ if (typeof text === "string" && (text.length > 0 || origin.messageId)) return { + type: "text_delta", + text, + stream: params.stream, +@@ -201,7 +201,7 @@ function resolveTextChunk(params) { + }; + } + const text = asString(params.payload.text); +- if (!text || text.length === 0) return null; ++ if (typeof text !== "string" || text.length === 0 && !origin.messageId) return null; + return { + type: "text_delta", + text, +@@ -350,6 +350,7 @@ function assignForwardedToolPayload(event, payload) { + if (Object.prototype.hasOwnProperty.call(snapshot, "rawInput")) event.rawInput = snapshot.rawInput; + if (Object.prototype.hasOwnProperty.call(snapshot, "rawOutput")) event.rawOutput = snapshot.rawOutput; + if (Array.isArray(snapshot.content)) event.content = snapshot.content; ++ if (isRecord(snapshot._meta)) event._meta = snapshot._meta; + } + function runtimeEventFromMessage(message) { + if (!isRecord(message)) return null; +@@ -397,11 +398,12 @@ function usageUpdateEvent(payload) { + used, + size, + cost: normalizeUsageCost(payload.cost), +- breakdown: normalizeUsageBreakdown(meta?.usage) ++ breakdown: normalizeUsageBreakdown(meta?.usage), ++ meta + }); + } + function buildUsageUpdateEvent(parts) { +- const { used, size, cost, breakdown } = parts; ++ const { used, size, cost, breakdown, meta } = parts; + return { + type: "status", + text: used != null && size != null ? `usage updated: ${used}/${size}` : "usage updated", +@@ -409,7 +411,8 @@ function buildUsageUpdateEvent(parts) { + ...used != null ? { used } : {}, + ...size != null ? { size } : {}, + ...cost ? { cost } : {}, +- ...breakdown ? { breakdown } : {} ++ ...breakdown ? { breakdown } : {}, ++ ...meta ? { _meta: meta } : {} + }; + } + function availableCommandsUpdateEvent(payload) { +@@ -483,7 +486,8 @@ function statusUpdateEvent(tag, payload) { + return { + type: "status", + text, +- tag ++ tag, ++ ...isRecord(payload._meta) ? { _meta: payload._meta } : {} + }; + } + function clientOperationEvent(payload) { +@@ -950,6 +954,12 @@ var AcpRuntimeManager = class { + agentProcessEnv: this.options.agentProcessEnv, + agentProcessEnvAllow: this.options.agentProcessEnvAllow, + agentProcessEnvBlock: this.options.agentProcessEnvBlock, ++ spawnAgent: this.options.spawnAgent, ++ clientCapabilities: this.options.clientCapabilities, ++ protocolGuardFactory: this.options.protocolGuardFactory, ++ extensionMethods: this.options.extensionMethods, ++ onExtensionRequest: this.options.onExtensionRequest, ++ onExtensionNotification: this.options.onExtensionNotification, + processLifecycle: { + ...options.processLifecycle, + onBeforeSpawn: async (launch) => { +@@ -1842,7 +1852,11 @@ var AcpRuntimeManager = class { + setSessionConfigOption: async (configId, value, authority) => { + return (await task.state.activeController.setResolvedSessionConfigOption(configId, value, authority)).response; + }, +- setResolvedSessionConfigOption: async (configId, value, authority) => await this.setRuntimeResolvedSessionConfigOption(task, turn, configId, value, authority) ++ setResolvedSessionConfigOption: async (configId, value, authority) => await this.setRuntimeResolvedSessionConfigOption(task, turn, configId, value, authority), ++ requestExtension: async (method, params, authority) => { ++ await this.waitForRuntimeControlSession(task, turn); ++ return await turn.client.requestExtension(method, params, authority); ++ } + }; + } + async waitForRuntimeControlSession(task, turn) { +@@ -2100,6 +2114,16 @@ var AcpRuntimeManager = class { + resolve: (connectedRecord) => resolveSupportedConfigOptionId(connectedRecord, key) + } }, authority); + } ++ async requestExtension(input) { ++ const recordId = input.handle.acpxRecordId ?? input.handle.sessionKey; ++ return await this.withManagerLock(this.runtimeOperationLocks, recordId, async () => { ++ const record = await this.requireRecord(recordId); ++ assertControlAuthority(input); ++ const controller = this.activeControllers.get(record.acpxRecordId); ++ if (controller) return await controller.requestExtension(input.method, input.params, input); ++ return await this.withRuntimeControlSession(record, input.sessionMode ?? "persistent", async ({ client }) => await client.requestExtension(input.method, input.params, input), void 0, input); ++ }, input); ++ } + async cancel(handle) { + await this.activeControllers.get(handle.acpxRecordId ?? handle.sessionKey)?.requestCancelActivePrompt(); + } +@@ -2779,6 +2803,14 @@ var AcpxRuntime = class { + const { handle, state } = this.resolveManagerHandle(input.handle); + return await (await this.getManager()).setConfigOption(handle, input.key, input.value, state.mode, input); + } ++ async requestExtension(input) { ++ const { handle, state } = this.resolveManagerHandle(input.handle); ++ return await (await this.getManager()).requestExtension({ ++ ...input, ++ handle, ++ sessionMode: input.sessionMode ?? state.mode ++ }); ++ } + async cancel(input) { + const { handle } = this.resolveManagerHandle(input.handle); + await (await this.getManager()).cancel(handle); +diff --git a/dist/session-options-D9KL7-d8.d.ts b/dist/session-options-D9KL7-d8.d.ts +index d6d04c0..c8dda13 100644 +--- a/dist/session-options-D9KL7-d8.d.ts ++++ b/dist/session-options-D9KL7-d8.d.ts +@@ -1,4 +1,5 @@ + import { AgentCapabilities, AnyMessage, ContentBlock, CreateElicitationRequest, ElicitationContentValue, JsonRpcId, McpServer, McpServer as McpServer$1, RequestPermissionRequest, SessionConfigOption, SessionNotification, SetSessionConfigOptionResponse, ToolKind } from "@agentclientprotocol/sdk"; ++import { ChildProcess, SpawnOptionsWithoutStdio } from "node:child_process"; + type PromptInput = ContentBlock[]; + type AcpPermissionRequest = { + sessionId: string; +@@ -177,6 +178,24 @@ type AcpClientOptions = { + onSessionUpdate?: (notification: SessionNotification) => void; + onClientOperation?: (operation: ClientOperation) => void; + onPermissionEscalation?: (event: PermissionEscalationEvent) => void; ++ /** Ephemeral initialize capabilities; mandatory base capabilities take precedence. */ ++ clientCapabilities?: Record; ++ /** Closed list of inbound extension request and notification method names. */ ++ extensionMethods?: readonly string[]; ++ /** Bound to the current connection and active prompt; never persisted. */ ++ onExtensionRequest?: (method: string, params: Record, context: { ++ requestId: string | number; ++ signal: AbortSignal; ++ }) => Promise>; ++ onExtensionNotification?: (method: string, params: Record) => void; ++ /** Host-owned verified executable launch. */ ++ spawnAgent?: (input: { ++ command: string; ++ args: readonly string[]; ++ options: SpawnOptionsWithoutStdio; ++ }) => ChildProcess; ++ /** Synchronous admission guard. Exceptions fail the connection, never an observer. */ ++ protocolGuardFactory?: () => (direction: "inbound" | "outbound", message: unknown) => void; + onPermissionRequest?: AcpPermissionHandler; + }; + declare const SESSION_RECORD_SCHEMA: "acpx.session.v1"; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a20325bff3a..1e87869239d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,8 +18,7 @@ packages: patchedDependencies: postgres@3.4.9: patches/postgres@3.4.9.patch embedded-postgres@18.1.0-beta.16: patches/embedded-postgres@18.1.0-beta.16.patch - acpx@0.12.0: patches/acpx@0.12.0.patch - acpx@0.13.1: patches/acpx@0.13.1.patch + acpx@0.22.0: patches/acpx@0.22.0.patch "@agentclientprotocol/claude-agent-acp@0.73.0": patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch "@agentclientprotocol/codex-acp@1.6.2": patches/@agentclientprotocol__codex-acp@1.6.2.patch "@chat-adapter/slack@4.39.0": patches/@chat-adapter__slack@4.39.0.patch diff --git a/rules/acpx-profile-identity.mdc b/rules/acpx-profile-identity.mdc index b27c138a771..09537c359b1 100644 --- a/rules/acpx-profile-identity.mdc +++ b/rules/acpx-profile-identity.mdc @@ -22,7 +22,7 @@ Three things decide whether saved work and installed packs stay compatible: | What changed | What carries it | Effect | | --- | --- | --- | | The provider's upstream release: package versions, platform closures, a launcher shipped verbatim | The profile's `commandDigest` | Saved sessions for that provider start fresh | -| The shared ACPX layer | The profile's `acpxVersion` and the Runner's ACPX patch in the pack `contractDigest` | The same for every provider | +| The shared ACPX layer | The profile's `acpxVersion` and the Runner's ACPX patch in the pack `contractDigest` | Saved sessions carry forward for every provider when the new ACPX loads the old records; list each carried release in `CARRIED_ACPX_RELEASES` and test the resume | | A runner/pack contract: schemas, generated catalogs, Rust shapes and env keys | The provider pack's `contractDigest` | Older image packs are not reused | Ordinary Runner source changes, refactors and comments change none of these. @@ -52,6 +52,14 @@ Ordinary Runner source changes, refactors and comments change none of these. through the current one decode, so recovery can refuse them. Launch and recovery admission require the exact current profile and digest. +- **Carry sessions across ACPX upgrades.** An ACPX upgrade keeps saved + sessions when the new ACPX loads the session records the old one wrote. Add + the old release to `CARRIED_ACPX_RELEASES` (`acpx_provider_backend.rs`), keep + the session identity's ACPX value (`SESSION_IDENTITY_ACPX_VERSION`), and add + a resume test with a record the old release wrote. An ACPX release whose + records the new one cannot load is not listed, and its sessions are refused + as `provider_state_incompatible`. + ## Adding a provider 1. Add its profile at version 1 to `acpx-profiles.json`. diff --git a/scripts/acpx-patch-packaging.test.mjs b/scripts/acpx-patch-packaging.test.mjs index d502eaebab8..4508e73bea5 100644 --- a/scripts/acpx-patch-packaging.test.mjs +++ b/scripts/acpx-patch-packaging.test.mjs @@ -17,7 +17,6 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { createRequire } from "node:module"; -import { runInNewContext } from "node:vm"; import cliEsbuildConfig from "../cli/esbuild.config.mjs"; import { bundledCliNpmDependencies } from "./cli-bundled-npm-dependencies.mjs"; @@ -42,7 +41,7 @@ function esbuildFixture(t) { writeFileSync(join(location, "package.json"), JSON.stringify(metadata)); return location; }; - const acpx = writePackage(join(graph, "acpx"), { name: "acpx", version: "0.13.1" }); + const acpx = writePackage(join(graph, "acpx"), { name: "acpx", version: "0.22.0" }); const esbuild = writePackage(join(acpx, "node_modules/esbuild"), { name: "esbuild", version: "0.28.2", scripts: { postinstall: "node install.js" }, optionalDependencies: Object.fromEntries([...nativeTargets, "win32-x64"].map(target => [`@esbuild/${target}`, "0.28.2"])), @@ -52,7 +51,7 @@ function esbuildFixture(t) { }); writePackage(join(graph, "unrelated-linux-x64"), { name: "unrelated-linux-x64", version: "1.0.0" }); const publish = { name: "@paperclipai/adapter-utils", version: "0.3.1", bundleDependencies: ["acpx"], - dependencies: { acpx: "0.13.1" }, optionalDependencies: { unrelated: "1.0.0" } }; + dependencies: { acpx: "0.22.0" }, optionalDependencies: { unrelated: "1.0.0" } }; return { directory, graph, esbuild, producerBinary, publish, writePackage }; } const codexTargets = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "win32-x64", "win32-arm64"]; @@ -121,7 +120,7 @@ const releaseLib = await readFile(new URL("./release-lib.sh", import.meta.url), const buildNpmScript = await readFile(new URL("./build-npm.sh", import.meta.url), "utf8"); const acpxRuntimePatch = await readFile( - new URL("../patches/acpx@0.13.1.patch", import.meta.url), + new URL("../patches/acpx@0.22.0.patch", import.meta.url), "utf8", ); const claudeAcpPatch = await readFile( @@ -129,7 +128,7 @@ const claudeAcpPatch = await readFile( "utf8", ); -for (const version of ["0.12.0", "0.13.1"]) { +for (const version of ["0.22.0"]) { test(`ACPX ${version} release patch uses portable generated unified hunks`, async () => { const patch = await readFile( new URL(`../patches/acpx@${version}.patch`, import.meta.url), @@ -189,16 +188,12 @@ for (const version of ["0.12.0", "0.13.1"]) { test("published packages preserve the patched ACPX runtime", () => { assert.equal( - rootPackage.pnpm.patchedDependencies["acpx@0.12.0"], - "patches/acpx@0.12.0.patch", + rootPackage.pnpm.patchedDependencies["acpx@0.22.0"], + "patches/acpx@0.22.0.patch", ); - assert.equal( - rootPackage.pnpm.patchedDependencies["acpx@0.13.1"], - "patches/acpx@0.13.1.patch", - ); - assert.equal(adapterUtilsPackage.dependencies.acpx, "0.12.0"); + assert.equal(adapterUtilsPackage.dependencies.acpx, "0.22.0"); assert.deepEqual(adapterUtilsPackage.bundleDependencies, ["acpx"]); - assert.equal(serverPackage.dependencies.acpx, "0.13.1"); + assert.equal(serverPackage.dependencies.acpx, "0.22.0"); assert.ok(serverPackage.bundleDependencies.includes("acpx")); assert.equal(bundledCliNpmDependencies.has("acpx"), true); assert.equal(cliEsbuildConfig.external.includes("acpx"), false); @@ -413,19 +408,22 @@ test("bundled esbuild cannot follow a platform payload or package namespace outs }); test("Paperclip Runner pins the qualified ACPX host callbacks", () => { - assert.equal(rootPackage.pnpm.patchedDependencies["acpx@0.13.1"], "patches/acpx@0.13.1.patch"); + assert.equal(rootPackage.pnpm.patchedDependencies["acpx@0.22.0"], "patches/acpx@0.22.0.patch"); assert.equal( rootPackage.pnpm.patchedDependencies["@agentclientprotocol/claude-agent-acp@0.73.0"], "patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch", ); - assert.equal(runnerPackage.dependencies.acpx, "0.13.1"); + assert.equal(runnerPackage.dependencies.acpx, "0.22.0"); assert.equal(runnerPackage.dependencies["@agentclientprotocol/claude-agent-acp"], "0.73.0"); assert.equal(runnerPackage.dependencies["@agentclientprotocol/codex-acp"], "1.6.2"); for (const callback of [ - "spawnEnvironment", "spawnCwd", "spawnAgent", "isPlainStringEnvironment", - "onAgentSpawn", "onAgentStderr", "onAgentExit", - "onSessionNotification", "onClientOperation", + "spawnAgent", "requestExtension", "protocolGuardFactory", ]) assert.match(acpxRuntimePatch, new RegExp(callback)); + // Moved to stock 0.22 hooks (spawnAgent, processLifecycle, onAcpMessage). + const added = acpxRuntimePatch.split("\n").filter(line => line.startsWith("+")).join("\n"); + for (const removed of ["spawnCwd", "onAgentSpawn", "onAgentStderr", "onSessionNotification", "onClientOperation", "spawnEnvironment", "agentGoalCapability", "onAgentInitialize"]) { + assert.doesNotMatch(added, new RegExp(removed)); + } assert.match(claudeAcpPatch, /usage: \{/); assert.match(claudeAcpPatch, /cache_creation_input_tokens/); }); @@ -628,7 +626,7 @@ node -e 'const fs=require("node:fs");fs.cpSync(process.env.FAKE_NATIVE_ARTIFACTS mkdir -p node_modules/acpx/dist node -e 'const fs=require("node:fs");fs.mkdirSync("node_modules/esbuild",{recursive:true});fs.writeFileSync("node_modules/esbuild/package.json",JSON.stringify({name:"esbuild",version:"0.28.2",scripts:{postinstall:"node install.js"},optionalDependencies:Object.fromEntries(["linux-x64","darwin-arm64","darwin-x64"].map(target=>["@esbuild/"+target,"0.28.2"]))}));fs.mkdirSync("node_modules/@esbuild/linux-x64",{recursive:true});fs.writeFileSync("node_modules/@esbuild/linux-x64/package.json",JSON.stringify({name:"@esbuild/linux-x64",version:"0.28.2",os:["linux"],cpu:["x64"]}));' printf 'unpatched runtime\\n' > node_modules/acpx/dist/runtime.js -printf '{"name":"acpx","version":"0.13.1"}\\n' > node_modules/acpx/package.json +printf '{"name":"acpx","version":"0.22.0"}\\n' > node_modules/acpx/package.json `, ); writeExecutable( @@ -650,10 +648,9 @@ printf '%s\\n' "$patch_input" > "$target/applied.patch" if [[ "$target" != */acpx ]]; then exit 0 fi -grep -q spawnEnvironment <<< "$patch_input" +grep -q requestExtension <<< "$patch_input" grep -q spawnAgent <<< "$patch_input" -grep -q onAgentStderr <<< "$patch_input" -printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js" +printf 'patched requestExtension runtime\\n' > "$target/dist/runtime.js" `, ); @@ -697,7 +694,7 @@ printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js" assert.equal(JSON.parse(readFileSync(join(destinationDir, "node_modules/esbuild/package.json"))).scripts.postinstall, "node install.js"); assert.match( readFileSync(join(stagedAcpxDir, "dist/runtime.js"), "utf8"), - /spawnEnvironment/, + /requestExtension/, ); assert.match( readFileSync(callLog, "utf8"), @@ -747,21 +744,3 @@ test("npm builds use corepack instead of requiring a global pnpm", () => { }); -test("installed ACPX runtime persists and restores optional goal capabilities", () => { - const requireRunner = createRequire(new URL("../packages/paperclip-runner/package.json", import.meta.url)); - const runtimeSource = readFileSync(requireRunner.resolve("acpx/runtime"), "utf8"); - const start = runtimeSource.indexOf("function persistedGoalCapability("); - const end = runtimeSource.indexOf("function planUpdateEvent(", start); - assert.ok(start >= 0 && end > start, "the installed patch must define both goal helpers"); - const helpers = runInNewContext(runtimeSource.slice(start, end) + ";({ persistedGoalCapability, restoredGoalCapability })", { - isRecord: (value) => value !== null && typeof value === "object" && !Array.isArray(value), - }); - assert.equal(helpers.persistedGoalCapability(undefined), undefined); - assert.equal(helpers.restoredGoalCapability(undefined), undefined); - const goal = { version: 1, controlMethod: "_session/goal", actions: ["set", "pause", "clear"] }; - const saved = JSON.parse(JSON.stringify(helpers.persistedGoalCapability(goal))); - assert.equal(saved.control_method, "_session/goal"); - assert.deepEqual(JSON.parse(JSON.stringify(helpers.restoredGoalCapability(saved))), goal); - assert.equal(helpers.persistedGoalCapability({ ...goal, version: 2 }), undefined); - assert.equal(helpers.persistedGoalCapability({ ...goal, actions: ["set"] }), undefined); -}); diff --git a/scripts/prepare-bundled-package.mjs b/scripts/prepare-bundled-package.mjs index 528ece8bfb5..f21e4c84084 100644 --- a/scripts/prepare-bundled-package.mjs +++ b/scripts/prepare-bundled-package.mjs @@ -417,8 +417,7 @@ export function prepareBundledPackage(sourceDir, destinationDir, { sourceRoot = readFileSync(resolve(destinationDir, "node_modules/acpx/package.json"), "utf8"), ); const expectedPatchMarker = { - "0.12.0": "onAgentStderr", - "0.13.1": "spawnEnvironment", + "0.22.0": "requestExtension", }[acpxPackage.version]; const acpxRuntime = readFileSync( resolve(destinationDir, "node_modules/acpx/dist/runtime.js"), diff --git a/server/package.json b/server/package.json index 24877a9bd28..ee5e7622502 100644 --- a/server/package.json +++ b/server/package.json @@ -86,7 +86,7 @@ "@photon-ai/advanced-imessage": "2.2.0", "@vercel/connect": "2.3.3", "acorn": "8.18.0", - "acpx": "0.13.1", + "acpx": "0.22.0", "ai": "7.0.130", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", diff --git a/server/scripts/prepare-runner-vendor.test.mjs b/server/scripts/prepare-runner-vendor.test.mjs index d42be80df18..f9cfff2b3fd 100644 --- a/server/scripts/prepare-runner-vendor.test.mjs +++ b/server/scripts/prepare-runner-vendor.test.mjs @@ -49,7 +49,7 @@ function makeRepo() { "pnpm-lock.yaml": "lockfileVersion: 9\n", "pnpm-workspace.yaml": "packages:\n - packages/*\n - server\n", "tsconfig.base.json": "{}\n", - "patches/acpx@0.13.1.patch": "--- a\n+++ b\n", + "patches/acpx@0.22.0.patch": "--- a\n+++ b\n", "server/package.json": '{"name":"@paperclipai/server"}\n', "packages/paperclip-runner/package.json": '{"name":"@paperclipai/paperclip-runner"}\n', "packages/paperclip-runner/src/index.ts": "export const one = 1;\n", diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 654dba53d8d..b77374934e2 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -2220,7 +2220,7 @@ describe("native provider usage normalization", () => { expect(resolveNativeBilling({ kind: "codex", model: "gpt-6.1-sol" } as NativeExecutionInput["provider"], { CODEX_API_KEY: "fixture" })) .toEqual({ provider: "openai", biller: "openai", billingType: "metered_api" }); expect(resolveNativeBilling({ kind: "acpx", agent: "claude", model: "claude-test", permissionMode: "approve-all", - profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.13.1", agent: "claude", agentProfileVersion: 1, + profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.22.0", agent: "claude", agentProfileVersion: 1, agentServerPackage: "@zed-industries/claude-agent-acp", agentServerVersion: "1", agentRuntimePackage: null, agentRuntimeVersion: null, commandDigest: "fixture" }, }, { ANTHROPIC_API_KEY: "test" })) @@ -2350,7 +2350,7 @@ describe("remote provider pack manifest", () => { compatibility: { opencode: 1, acpx: 1 }, contractDigest: digest, target: { platform: "linux", architecture }, runnerSourceRevision: "a".repeat(40), pins: { nodeMinimum: "24.11.0", codex: QUALIFIED_ACPX_PROFILES.codex.agentRuntimeVersion, - opencode: "1.18.34", acpx: "0.13.1", grok: QUALIFIED_ACPX_PROFILES.grok.agentRuntimeVersion, + opencode: "1.18.34", acpx: "0.22.0", grok: QUALIFIED_ACPX_PROFILES.grok.agentRuntimeVersion, claudeAcp: QUALIFIED_ACPX_PROFILES.claude.agentServerVersion, codexAcp: QUALIFIED_ACPX_PROFILES.codex.agentServerVersion }, acpxProfileDigests: Object.fromEntries(["grok", "claude", "codex"].map(agent => [agent, QUALIFIED_ACPX_PROFILES[agent as "grok" | "claude" | "codex"].commandDigest])), @@ -2825,7 +2825,7 @@ describe("remote provider pack manifest", () => { nodeMinimum: "24.11.0", codex: "0.160.0", opencode: "1.18.34", - acpx: "0.13.1", + acpx: "0.22.0", claudeAcp: "0.73.0", codexAcp: "1.6.2", grok: "1.0.13", @@ -6728,7 +6728,7 @@ describe("stopped native conversation physical cleanup", () => { process.env.PAPERCLIP_RUNNER_STATE_DIR = base; const input = parseNativeExecutionInput({ ...execution, provider: provider === "codex" ? execution.provider : { kind: "acpx", agent: "claude", model: "claude-sonnet-5", permissionPolicy: "interactive", - profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.13.1", agent: "claude", agentProfileVersion: 1, + profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.22.0", agent: "claude", agentProfileVersion: 1, agentServerPackage: "@zed-industries/claude-agent-acp", agentServerVersion: "1", agentRuntimePackage: null, agentRuntimeVersion: null, commandDigest: "fixture" } }, session: { ...execution.session, driverKind: provider === "codex" ? "codex_app_server" : "acpx_runtime" }, @@ -7665,7 +7665,7 @@ describe("native startup cancellation fence", () => { productionLock: "pnpm-lock.yaml", opencodeCommand: "node_modules/.bin/opencode", opencodeExecutable: "node_modules/opencode-ai/bin/opencode.exe", opencodeProxy: "dist/cli/opencode-app-server-proxy.cjs", acpxSidecar: "dist/cli/acpx-runtime-sidecar.cjs" }).map(([name, path]) => [name, { path, sha256: sha }])); const payload = { compatibility: { opencode: 1, acpx: 1 }, contractDigest: sha, target: { platform: "linux", architecture: "x64" }, runnerSourceRevision: "a".repeat(40), - pins: { nodeMinimum: "24.11.0", codex: QUALIFIED_ACPX_PROFILES.codex.agentRuntimeVersion, opencode: "1.18.34", acpx: "0.13.1", + pins: { nodeMinimum: "24.11.0", codex: QUALIFIED_ACPX_PROFILES.codex.agentRuntimeVersion, opencode: "1.18.34", acpx: "0.22.0", grok: QUALIFIED_ACPX_PROFILES.grok.agentRuntimeVersion, claudeAcp: QUALIFIED_ACPX_PROFILES.claude.agentServerVersion, codexAcp: QUALIFIED_ACPX_PROFILES.codex.agentServerVersion }, acpxProfileDigests: Object.fromEntries(["grok", "claude", "codex"].map(agent => [agent, QUALIFIED_ACPX_PROFILES[agent as "grok" | "claude" | "codex"].commandDigest])), artifacts, distDigest: sha, bridgeDigest: "sha256:" + createHash("sha256").update(sha + "\n" + sha + "\n" + sha).digest("hex") }; @@ -16557,7 +16557,7 @@ describe("runnerd provider runtime wiring", () => { const payload = { compatibility: { opencode: 1, acpx: 1 }, contractDigest: digest, target: { platform: "linux", architecture: "x64" }, runnerSourceRevision: "a".repeat(40), pins: { nodeMinimum: "24.11.0", codex: QUALIFIED_ACPX_PROFILES.codex.agentRuntimeVersion, - opencode: "1.18.34", acpx: "0.13.1", grok: QUALIFIED_ACPX_PROFILES.grok.agentRuntimeVersion, + opencode: "1.18.34", acpx: "0.22.0", grok: QUALIFIED_ACPX_PROFILES.grok.agentRuntimeVersion, claudeAcp: QUALIFIED_ACPX_PROFILES.claude.agentServerVersion, codexAcp: QUALIFIED_ACPX_PROFILES.codex.agentServerVersion }, acpxProfileDigests: Object.fromEntries(["grok", "claude", "codex"].map(agent => [agent, QUALIFIED_ACPX_PROFILES[agent as "grok" | "claude" | "codex"].commandDigest])), @@ -16672,7 +16672,7 @@ describe("runnerd provider runtime wiring", () => { const payload = { compatibility: { opencode: 1, acpx: 1 }, contractDigest: digest, target: { platform: "linux", architecture: "x64" }, runnerSourceRevision: "a".repeat(40), pins: { nodeMinimum: "24.11.0", codex: QUALIFIED_ACPX_PROFILES.codex.agentRuntimeVersion, - opencode: "1.18.34", acpx: "0.13.1", grok: QUALIFIED_ACPX_PROFILES.grok.agentRuntimeVersion, + opencode: "1.18.34", acpx: "0.22.0", grok: QUALIFIED_ACPX_PROFILES.grok.agentRuntimeVersion, claudeAcp: QUALIFIED_ACPX_PROFILES.claude.agentServerVersion, codexAcp: QUALIFIED_ACPX_PROFILES.codex.agentServerVersion }, acpxProfileDigests: Object.fromEntries(["grok", "claude", "codex"].map(agent => [agent, QUALIFIED_ACPX_PROFILES[agent as "grok" | "claude" | "codex"].commandDigest])), @@ -21130,7 +21130,7 @@ describe("runnerd provider runtime wiring", () => { profile: { driverKind: "acpx_runtime", protocolVersion: 1, - acpxVersion: "0.13.1", + acpxVersion: "0.22.0", agent: "codex", agentProfileVersion: 1, agentServerPackage: "@agentclientprotocol/codex-acp", diff --git a/server/src/services/quarantined-task-retry.test.ts b/server/src/services/quarantined-task-retry.test.ts index 4ba6e37ea9a..a7123e3843c 100644 --- a/server/src/services/quarantined-task-retry.test.ts +++ b/server/src/services/quarantined-task-retry.test.ts @@ -42,7 +42,7 @@ describe("task Retry after inherited native cleanup quarantine", () => { const execution = (runId: string) => parseNativeExecutionInput({ schema: "paperclip.native-execution-input.v1", provider: provider === "acpx" ? { kind: "acpx", agent: "claude", model: "claude-sonnet-5", permissionPolicy: "interactive", - profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.13.1", agent: "claude", agentProfileVersion: 1, + profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.22.0", agent: "claude", agentProfileVersion: 1, agentServerPackage: "@zed-industries/claude-agent-acp", agentServerVersion: "1", agentRuntimePackage: null, agentRuntimeVersion: null, commandDigest: "fixture" } } : { kind: "codex", model: null }, binding: { companyId, agentId, issueId, runId, executionWorkspaceId: transient ? runId : workspaceId }, diff --git a/tests/runner-e2e/gemini-acp-filesystem.smoke.ts b/tests/runner-e2e/gemini-acp-filesystem.smoke.ts index c67cfc55717..e52854b8fe8 100644 --- a/tests/runner-e2e/gemini-acp-filesystem.smoke.ts +++ b/tests/runner-e2e/gemini-acp-filesystem.smoke.ts @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promi import os from "node:os"; import path from "node:path"; import { createAcpRuntime, createAgentRegistry, createRuntimeStore } from "../../packages/adapter-utils/node_modules/acpx/dist/runtime.js"; +import { stockAcpRuntimeOptions } from "../../packages/adapter-utils/src/acpx-engine/stock-runtime-options.js"; const argument = process.argv.indexOf("--agent-command"); const command = argument < 0 ? "gemini --acp" : process.argv[argument + 1]; @@ -46,18 +47,21 @@ try { security: { auth: { selectedType: "gemini-api-key" } }, telemetry: { enabled: false }, }), { mode: 0o600 }); active = { calls: 0, file, tool: scenario === "read-existing" ? "read_file" : "write_file" }; - const runtime = createAcpRuntime({ + const agentEnvironment = { + HOME: home, PATH: process.env.PATH ?? "", GEMINI_MODEL: "gemini-3.1-flash-lite", + GEMINI_API_KEY: "synthetic-invalid-key", GOOGLE_GEMINI_BASE_URL: origin, + }; + const runtime = createAcpRuntime(stockAcpRuntimeOptions({ cwd, sessionStore: createRuntimeStore({ stateDir: path.join(root, scenario, "state") }), agentRegistry: createAgentRegistry({ overrides: { gemini: command } }), - permissionMode: "approve-all", nonInteractivePermissions: "deny", inheritProcessEnv: false, + permissionMode: "approve-all", nonInteractivePermissions: "deny", + // Exactly this environment, as the engine launches agents. + agentEnvironment: () => agentEnvironment, onAgentStderr: () => { /* Child diagnostics may contain untrusted material. */ }, - }); + })); const handle = await runtime.ensureSession({ sessionKey: `gemini-filesystem-${scenario}`, agent: "gemini", mode: "oneshot", cwd, - sessionOptions: { env: { - HOME: home, PATH: process.env.PATH ?? "", GEMINI_MODEL: "gemini-3.1-flash-lite", - GEMINI_API_KEY: "synthetic-invalid-key", GOOGLE_GEMINI_BASE_URL: origin, - } }, + sessionOptions: { env: agentEnvironment }, }); try { let completedTools = 0; let failedTools = 0; let missingFileSignal = false; diff --git a/tests/runner-e2e/source-sdk-public-inputs.json b/tests/runner-e2e/source-sdk-public-inputs.json index ca7eb258c48..4f2ed9edc10 100644 --- a/tests/runner-e2e/source-sdk-public-inputs.json +++ b/tests/runner-e2e/source-sdk-public-inputs.json @@ -1,6 +1,6 @@ { "inputs": { - "package.json": "88c3caa4d83b43e4c3aa473d8a64da053d17662c", + "package.json": "e704ca28508fa7c887f704fc26603cf8fd623867", "packages/plugins/sdk/package.json": "b7a4936e2803fd0d3b32dd7f17fc1ea86609ead0", "packages/plugins/sdk/src/bundlers.ts": "883bf3d9f2bd42877cfc0c3707cd882a6f6248d0", "packages/plugins/sdk/src/define-plugin.ts": "eb6530c006722d22f55355ec810745f1524b874a",