From e2b89b63027188c120dc77beb3d6a6dd8428bee6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 3 Oct 2026 10:28:19 -0500 Subject: [PATCH] Integrate pinned Cursor ACP runtime and explicit public setup Consolidate Cursor v10 onto current controller behavior, retain admission gates, and prepare native interaction and cleanup qualification. Add the public provisioner and ordinary provider-pack assets. Co-Authored-By: Paperclip --- cli/src/__tests__/runtime-setup.test.ts | 38 + cli/src/commands/runtime.ts | 50 + cli/src/index.ts | 2 + .../runner-cursor-capabilities.md | 552 ++++ .../2026-10-03-cursor-production-readiness.md | 70 + docker/daytona-runner/Dockerfile | 19 +- docker/daytona-runner/README.md | 6 + packages/adapter-utils/src/index.ts | 1 + .../src/paperclip-runner-permissions.ts | 15 + packages/adapter-utils/src/types.ts | 2 + .../codex-local/src/ui/build-config.ts | 10 +- .../paperclip-runner/cursor-contract.json | 22 + .../cursor-distributions.json | 31 + .../runner-core/src/acpx_event_payload.rs | 39 +- .../runner-core/src/acpx_provider_backend.rs | 453 +++- .../src/acpx_provider_checkpoint.rs | 7 +- .../runner-core/src/acpx_provider_session.rs | 230 +- .../runner-core/src/acpx_provider_state.rs | 80 +- .../runner-core/src/acpx_sidecar_transport.rs | 7 + .../runner-core/src/bin/fake-acpx-sidecar.rs | 93 +- .../crates/runner-core/src/durable/state.rs | 24 + .../crates/runner-core/src/provider_events.rs | 14 +- .../runner-core/tests/acpx_event_payload.rs | 120 + .../tests/acpx_event_projection.rs | 53 + .../tests/acpx_provider_checkpoint.rs | 33 + .../tests/acpx_provider_resolutions.rs | 64 + .../tests/acpx_provider_session.rs | 99 + .../runner-core/tests/acpx_provider_state.rs | 149 ++ .../tests/acpx_provider_suspend.rs | 1 + .../runner-core/tests/acpx_provider_turns.rs | 26 + .../tests/local_integrity_boundary_golden.rs | 1 + .../tests/native_provider_backend.rs | 2 +- .../scripts/build-node-startup-timeout.mjs | 6 + .../build-node-startup-timeout.test.mjs | 24 + .../scripts/build-provider-pack.mjs | 10 +- .../build-verified-provider-entrypoints.mjs | 9 +- .../scripts/candidate-provider-pack.mjs | 12 + .../scripts/candidate-provider-pack.test.mjs | 13 +- .../scripts/cursor-native-usage-source.json | 1 + .../scripts/cursor-native-usage.mjs | 81 + .../scripts/cursor-native-usage.test.mjs | 136 + .../scripts/cursor-runtime-patch.mjs | 101 + .../scripts/installed-package-pack.test.mjs | 139 + .../scripts/lib/installed-package-pack.mjs | 30 + .../materialize-cursor-distribution.mjs | 124 + .../materialize-cursor-distribution.test.mjs | 84 + .../scripts/provision-cursor.mjs | 25 + .../scripts/qualify-cursor-denial.mjs | 239 ++ .../scripts/qualify-cursor-denial.test.mjs | 62 + .../scripts/qualify-cursor-instructions.mjs | 60 + .../scripts/qualify-cursor-mode.mjs | 63 + .../scripts/qualify-cursor-native-usage.mjs | 142 ++ .../scripts/qualify-cursor-runtime-patch.mjs | 89 + .../src/backends/codex-acpx-native-backend.ts | 4 +- .../src/backends/harness-driver-backend.ts | 34 +- .../src/backends/native-backend-factory.ts | 1 + .../src/cli/acpx-runtime-sidecar.test.ts | 7 +- .../src/cli/acpx-runtime-sidecar.ts | 95 +- .../src/cli/acpx-sidecar-input.ts | 2 + .../src/cli/acpx-sidecar-locations.ts | 145 +- .../src/cli/eval-session-contract.test.ts | 44 +- .../paperclip-runner/src/cli/eval-session.ts | 37 +- .../src/contracts/harness-driver.ts | 2 + .../src/contracts/native-execution.ts | 15 +- .../drivers/acpx/acp-permission-adapter.ts | 17 +- .../src/drivers/acpx/acp-question-adapter.ts | 9 + .../drivers/acpx/agent-files-binding.test.ts | 56 + .../src/drivers/acpx/agent-files-binding.ts | 48 + .../src/drivers/acpx/capability-profiles.ts | 6 +- .../src/drivers/acpx/codex-acpx-driver.ts | 58 +- .../acpx/codex-runtime-adapter.test.ts | 114 +- .../src/drivers/acpx/codex-runtime-adapter.ts | 75 +- .../drivers/acpx/cursor-extensions.test.ts | 228 ++ .../src/drivers/acpx/cursor-extensions.ts | 492 ++++ .../drivers/acpx/cursor-installation.test.ts | 34 + .../src/drivers/acpx/cursor-installation.ts | 45 + .../drivers/acpx/cursor-instructions.test.ts | 180 ++ .../src/drivers/acpx/cursor-instructions.ts | 87 + .../drivers/acpx/cursor-launch-policy.test.ts | 25 + .../src/drivers/acpx/cursor-launch-policy.ts | 63 + .../src/drivers/acpx/cursor-mode.test.ts | 198 ++ .../src/drivers/acpx/cursor-mode.ts | 107 + .../acpx/cursor-permission-identity.test.ts | 101 + .../drivers/acpx/cursor-plan-tool-identity.ts | 23 + .../acpx/cursor-sdk-interactions.test.ts | 76 + .../drivers/acpx/cursor-tool-evidence.test.ts | 163 ++ .../src/drivers/acpx/cursor-tool-evidence.ts | 126 + .../src/drivers/acpx/profile-extensions.ts | 9 +- .../drivers/acpx/profile-installation.test.ts | 26 + .../src/drivers/acpx/profile-installation.ts | 41 +- .../drivers/acpx/provider-assets-root.test.ts | 29 +- .../src/drivers/acpx/provider-assets-root.ts | 12 +- .../src/drivers/acpx/qualified-profiles.ts | 8 +- .../src/drivers/acpx/recovery-identity.ts | 16 +- .../src/drivers/acpx/runtime-host.test.ts | 34 +- .../src/drivers/acpx/runtime-host.ts | 52 +- .../src/drivers/acpx/runtime-sandbox.ts | 2 + .../src/drivers/acpx/safe-locations.ts | 139 + .../src/drivers/acpx/sidecar-protocol.ts | 2 + .../drivers/acpx/single-read-evidence.test.ts | 55 + .../src/drivers/acpx/single-read-evidence.ts | 47 + .../drivers/acpx/turn-cancellation.test.ts | 102 + .../src/drivers/acpx/turn-cancellation.ts | 95 + .../src/drivers/acpx/usage-accounting.ts | 67 + .../src/live/cursor-usage-notice.ts | 98 + .../paperclip-runner/src/live/live-session.ts | 24 + ...runnerd-acpx-permission-forwarding.test.ts | 117 + .../runnerd-acpx-permission-ordering.test.ts | 221 ++ .../src/live/runnerd-acpx-permissions.test.ts | 72 + .../src/live/runnerd-codex-transport.ts | 188 +- ...cursor-subagents-package-contract.test.mjs | 109 + ...cpx-cursor-usage-package-contract.test.mjs | 173 ++ .../test/acpx-sidecar-contract.test.mjs | 13 + .../authenticated-capability-probe.json | 2271 +++++++++++++++++ .../authenticated-qualification-attempts.json | 922 +++++++ .../cursor-acp/credential-free-probe.json | 152 ++ .../instructions-v4-offline-proof.json | 44 + .../cursor-acp/linux-image-offline-proof.json | 47 + .../cursor-acp/mode-v5-offline-proof.json | 32 + .../cursor-acp/native-denial-proof.json | 756 ++++++ .../native-usage-v3-offline-proof.json | 56 + .../native-usage-v4-offline-proof.json | 59 + .../production-v3-offline-build.json | 236 ++ .../cursor-acp/profile-v10-identity.json | 59 + .../cursor-acp/profile-v3-identity.json | 43 + .../cursor-acp/profile-v4-identity.json | 46 + .../cursor-acp/profile-v5-identity.json | 53 + .../cursor-acp/profile-v6-identity.json | 54 + .../cursor-acp/profile-v7-identity.json | 55 + .../cursor-acp/profile-v8-identity.json | 55 + .../cursor-acp/profile-v9-identity.json | 55 + .../provider-pack-darwin-arm64.json | 67 + .../provider-pack-offline-proof.json | 204 ++ .../runtime-patch-initialization.json | 31 + .../runtime-patch-offline-proof.json | 80 + .../usage-v3-runtime-patch-offline-proof.json | 80 + .../usage-v4-runtime-patch-offline-proof.json | 80 + patches/acpx@0.13.1.patch | 354 ++- ...e-cancellation-request.integration.test.ts | 163 ++ server/src/routes/agents.ts | 3 + server/src/services/heartbeat.ts | 63 +- .../native-runtime/completion-contracts.ts | 20 +- .../native-agent-runtime-inheritance.ts | 1 + .../native-cancellation-request.test.ts | 78 + .../native-cancellation-request.ts | 133 + .../native-completion-feedback.test.ts | 41 + .../native-completion-feedback.ts | 2 +- .../native-cursor-plan-wait.test.ts | 159 ++ .../native-runtime/native-cursor-plan-wait.ts | 243 ++ .../native-runtime/native-execution-input.ts | 4 + .../native-runtime/native-run-finalizer.ts | 14 + .../native-runner-file-handoff.ts | 10 +- .../native-session-executor.test.ts | 11 + .../native-runtime/native-session-executor.ts | 77 +- .../native-runtime/native-session-resume.ts | 10 + .../native-runtime/provider-profile.ts | 13 + .../services/native-runtime/status-arbiter.ts | 17 + .../status-decision-committer.ts | 35 + server/src/services/recovery/service.ts | 4 + tests/runner-e2e/acpx-native-origin.ts | 9 + tests/runner-e2e/api.ts | 22 +- tests/runner-e2e/catalog.test.ts | 6 +- tests/runner-e2e/catalog.ts | 31 +- tests/runner-e2e/cleanup-verification.test.ts | 39 + tests/runner-e2e/cleanup-verification.ts | 37 + tests/runner-e2e/cursor-native-cases.test.ts | 136 + tests/runner-e2e/cursor-native-cases.ts | 139 + .../runner-e2e/cursor-native-evidence.test.ts | 105 + tests/runner-e2e/cursor-native-evidence.ts | 126 + tests/runner-e2e/cursor-native-flow.test.ts | 81 + tests/runner-e2e/cursor-native-flow.ts | 393 +++ tests/runner-e2e/daytona-image-content.ts | 11 + tests/runner-e2e/daytona-image.test.ts | 27 + tests/runner-e2e/failure-classifier.ts | 4 +- tests/runner-e2e/harness-env.ts | 8 +- tests/runner-e2e/launch.ts | 207 +- tests/runner-e2e/live-fixtures.ts | 2 +- .../runner-e2e/native-active-stop-evidence.ts | 235 ++ tests/runner-e2e/native-active-stop-flow.ts | 200 ++ tests/runner-e2e/native-active-stop-tasks.ts | 10 + tests/runner-e2e/native-active-stop.test.ts | 451 ++++ .../native-bootstrap-read-proof.test.ts | 115 + .../runner-e2e/native-bootstrap-read-proof.ts | 93 + tests/runner-e2e/native-local-fixtures.ts | 199 ++ tests/runner-e2e/native-remote-evidence.ts | 66 + tests/runner-e2e/prerequisites.ts | 12 + tests/runner-e2e/process-tree-owner.test.ts | 120 + tests/runner-e2e/process-tree-owner.ts | 188 ++ tests/runner-e2e/process-tree.ts | 87 + .../remote-native-bootstrap.test.ts | 410 +++ tests/runner-e2e/remote-native-bootstrap.ts | 218 ++ .../runner-e2e/remote-native-fixtures.test.ts | 694 +++++ tests/runner-e2e/remote-native-fixtures.ts | 488 ++++ tests/runner-e2e/runner.spec.ts | 448 ++-- tests/runner-e2e/server-stop.test.ts | 312 +++ tests/runner-e2e/server-stop.ts | 81 + tests/runner-e2e/server.ts | 86 +- tests/runner-e2e/types.ts | 4 +- ui/src/adapters/codex-local/config-fields.tsx | 21 +- 199 files changed, 20293 insertions(+), 851 deletions(-) create mode 100644 cli/src/__tests__/runtime-setup.test.ts create mode 100644 cli/src/commands/runtime.ts create mode 100644 doc/architecture/runner-cursor-capabilities.md create mode 100644 doc/plans/2026-10-03-cursor-production-readiness.md create mode 100644 packages/paperclip-runner/cursor-contract.json create mode 100644 packages/paperclip-runner/cursor-distributions.json create mode 100644 packages/paperclip-runner/scripts/build-node-startup-timeout.mjs create mode 100644 packages/paperclip-runner/scripts/build-node-startup-timeout.test.mjs create mode 100644 packages/paperclip-runner/scripts/cursor-native-usage-source.json create mode 100644 packages/paperclip-runner/scripts/cursor-native-usage.mjs create mode 100644 packages/paperclip-runner/scripts/cursor-native-usage.test.mjs create mode 100644 packages/paperclip-runner/scripts/cursor-runtime-patch.mjs create mode 100644 packages/paperclip-runner/scripts/installed-package-pack.test.mjs create mode 100644 packages/paperclip-runner/scripts/lib/installed-package-pack.mjs create mode 100644 packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs create mode 100644 packages/paperclip-runner/scripts/materialize-cursor-distribution.test.mjs create mode 100644 packages/paperclip-runner/scripts/provision-cursor.mjs create mode 100644 packages/paperclip-runner/scripts/qualify-cursor-denial.mjs create mode 100644 packages/paperclip-runner/scripts/qualify-cursor-denial.test.mjs create mode 100644 packages/paperclip-runner/scripts/qualify-cursor-instructions.mjs create mode 100644 packages/paperclip-runner/scripts/qualify-cursor-mode.mjs create mode 100644 packages/paperclip-runner/scripts/qualify-cursor-native-usage.mjs create mode 100644 packages/paperclip-runner/scripts/qualify-cursor-runtime-patch.mjs create mode 100644 packages/paperclip-runner/src/drivers/acpx/agent-files-binding.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/agent-files-binding.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/cursor-extensions.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/cursor-extensions.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/cursor-installation.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/cursor-installation.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/cursor-instructions.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/cursor-instructions.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/cursor-launch-policy.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/cursor-launch-policy.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/cursor-mode.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/cursor-mode.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/cursor-permission-identity.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/cursor-plan-tool-identity.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/cursor-sdk-interactions.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/profile-installation.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/safe-locations.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/single-read-evidence.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/turn-cancellation.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/turn-cancellation.ts create mode 100644 packages/paperclip-runner/src/live/cursor-usage-notice.ts create mode 100644 packages/paperclip-runner/src/live/runnerd-acpx-permission-forwarding.test.ts create mode 100644 packages/paperclip-runner/src/live/runnerd-acpx-permission-ordering.test.ts create mode 100644 packages/paperclip-runner/src/live/runnerd-acpx-permissions.test.ts create mode 100644 packages/paperclip-runner/test/acpx-cursor-subagents-package-contract.test.mjs create mode 100644 packages/paperclip-runner/test/acpx-cursor-usage-package-contract.test.mjs create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/authenticated-capability-probe.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/authenticated-qualification-attempts.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/credential-free-probe.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/instructions-v4-offline-proof.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/linux-image-offline-proof.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/mode-v5-offline-proof.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/native-denial-proof.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/native-usage-v3-offline-proof.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/native-usage-v4-offline-proof.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/production-v3-offline-build.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/profile-v10-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/profile-v3-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/profile-v4-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/profile-v5-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/profile-v6-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/profile-v7-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/profile-v8-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/profile-v9-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/provider-pack-darwin-arm64.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/provider-pack-offline-proof.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/runtime-patch-initialization.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/runtime-patch-offline-proof.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/usage-v3-runtime-patch-offline-proof.json create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/usage-v4-runtime-patch-offline-proof.json create mode 100644 server/src/__tests__/native-cancellation-request.integration.test.ts create mode 100644 server/src/services/native-runtime/native-cancellation-request.test.ts create mode 100644 server/src/services/native-runtime/native-cancellation-request.ts create mode 100644 server/src/services/native-runtime/native-completion-feedback.test.ts create mode 100644 server/src/services/native-runtime/native-cursor-plan-wait.test.ts create mode 100644 server/src/services/native-runtime/native-cursor-plan-wait.ts create mode 100644 tests/runner-e2e/acpx-native-origin.ts create mode 100644 tests/runner-e2e/cleanup-verification.test.ts create mode 100644 tests/runner-e2e/cleanup-verification.ts create mode 100644 tests/runner-e2e/cursor-native-cases.test.ts create mode 100644 tests/runner-e2e/cursor-native-cases.ts create mode 100644 tests/runner-e2e/cursor-native-evidence.test.ts create mode 100644 tests/runner-e2e/cursor-native-evidence.ts create mode 100644 tests/runner-e2e/cursor-native-flow.test.ts create mode 100644 tests/runner-e2e/cursor-native-flow.ts create mode 100644 tests/runner-e2e/native-active-stop-evidence.ts create mode 100644 tests/runner-e2e/native-active-stop-flow.ts create mode 100644 tests/runner-e2e/native-active-stop-tasks.ts create mode 100644 tests/runner-e2e/native-active-stop.test.ts create mode 100644 tests/runner-e2e/native-bootstrap-read-proof.test.ts create mode 100644 tests/runner-e2e/native-bootstrap-read-proof.ts create mode 100644 tests/runner-e2e/native-local-fixtures.ts create mode 100644 tests/runner-e2e/native-remote-evidence.ts create mode 100644 tests/runner-e2e/process-tree-owner.test.ts create mode 100644 tests/runner-e2e/process-tree-owner.ts create mode 100644 tests/runner-e2e/remote-native-bootstrap.test.ts create mode 100644 tests/runner-e2e/remote-native-bootstrap.ts create mode 100644 tests/runner-e2e/remote-native-fixtures.test.ts create mode 100644 tests/runner-e2e/remote-native-fixtures.ts create mode 100644 tests/runner-e2e/server-stop.test.ts create mode 100644 tests/runner-e2e/server-stop.ts diff --git a/cli/src/__tests__/runtime-setup.test.ts b/cli/src/__tests__/runtime-setup.test.ts new file mode 100644 index 00000000000..0c588ff6e51 --- /dev/null +++ b/cli/src/__tests__/runtime-setup.test.ts @@ -0,0 +1,38 @@ +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, expect, it } from "vitest"; +import { resolveCursorProvisioner } from "../commands/runtime.js"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); +async function fixture() { + const root = await realpath(await mkdtemp(join(tmpdir(), "cursor-public-setup-"))); + roots.push(root); + await mkdir(join(root, "dist/vendor/paperclip-runner/cli"), { recursive: true }); + await writeFile(join(root, "package.json"), JSON.stringify({ name: "@paperclipai/server" })); + await writeFile(join(root, "dist/index.js"), "throw new Error('must not import server');"); + const provisioner = join(root, "dist/vendor/paperclip-runner/cli/provision-cursor.cjs"); + await writeFile(provisioner, "fixture"); + return { root, provisioner, url: pathToFileURL(join(root, "dist/index.js")).href }; +} + +it("resolves the public provisioner without starting the server", async () => { + const f = await fixture(); + expect(await resolveCursorProvisioner(f.url)).toBe(f.provisioner); +}); + +it("rejects repository layouts and wrong package identities", async () => { + const f = await fixture(); + await mkdir(join(f.root, "src")); await writeFile(join(f.root, "src/index.ts"), "fixture"); + await expect(resolveCursorProvisioner(pathToFileURL(join(f.root, "src/index.ts")).href)).rejects.toThrow("published server layout"); + await writeFile(join(f.root, "package.json"), JSON.stringify({ name: "untrusted" })); + await expect(resolveCursorProvisioner(f.url)).rejects.toThrow("identity is invalid"); +}); + +it("rejects a provisioner linked outside the installed package", async () => { + const f = await fixture(); const other = await fixture(); + await rm(f.provisioner); await symlink(other.provisioner, f.provisioner); + await expect(resolveCursorProvisioner(f.url)).rejects.toThrow("escapes"); +}); diff --git a/cli/src/commands/runtime.ts b/cli/src/commands/runtime.ts new file mode 100644 index 00000000000..5afdc54be8e --- /dev/null +++ b/cli/src/commands/runtime.ts @@ -0,0 +1,50 @@ +import { spawn } from "node:child_process"; +import { lstat, readFile, realpath } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Command } from "commander"; + +/** Resolve the installed public dependency without importing or starting it. */ +export async function resolveCursorProvisioner(serverUrl: string): Promise { + const url = new URL(serverUrl); + if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Cursor setup requires an installed Paperclip server"); + const entry = await realpath(fileURLToPath(url)); + if (!entry.endsWith("/dist/index.js")) throw new Error("Cursor setup requires the published server layout"); + const root = resolve(dirname(entry), ".."); + const manifest = join(root, "package.json"); + const info = await lstat(manifest); + if (!info.isFile() || info.isSymbolicLink() || info.size > 65536 || JSON.parse(await readFile(manifest, "utf8")).name !== "@paperclipai/server") throw new Error("Cursor setup server package identity is invalid"); + const provisioner = join(root, "dist/vendor/paperclip-runner/cli/provision-cursor.cjs"); + if (await realpath(provisioner) !== provisioner || !(await lstat(provisioner)).isFile()) throw new Error("Cursor setup entrypoint escapes its server package"); + return provisioner; +} + +export async function setupCursorRuntime(): Promise { + const provisioner = await resolveCursorProvisioner(import.meta.resolve("@paperclipai/server")); + const child = spawn(process.execPath, [provisioner], { + stdio: "inherit", env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8" }, + }); + const cancel = () => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); }; + process.on("SIGINT", cancel); process.on("SIGTERM", cancel); + try { + await new Promise((accept, reject) => { + let spawnError: Error | undefined; + child.on("error", error => { spawnError = error; }); + child.once("close", (code, signal) => { + if (spawnError) reject(spawnError); + else if (code !== 0 || signal) reject(new Error("Cursor setup did not finish. Review its error; an invalid installation is never replaced automatically.")); + else accept(); + }); + }); + } finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); } +} + +export function registerRuntimeCommands(program: Command): void { + program.command("runtime").description("Manage explicitly installed agent runtimes") + .command("setup ") + .description("Install and verify the pinned Cursor runtime for this host (public downloads; no model calls)") + .action(async (provider: string) => { + if (provider !== "cursor") throw new Error("Supported runtime setup: paperclipai runtime setup cursor"); + await setupCursorRuntime(); + }); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index b2f722a4a00..44bb5e2f588 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -31,6 +31,7 @@ import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir. import { loadPaperclipEnvFile } from "./config/env.js"; import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js"; import { registerWorktreeCommands } from "./commands/worktree.js"; +import { registerRuntimeCommands } from "./commands/runtime.js"; import { registerPluginCommands } from "./commands/client/plugin.js"; import { registerClientAuthCommands } from "./commands/client/auth.js"; import { registerConnectCommand } from "./commands/client/connect.js"; @@ -260,6 +261,7 @@ registerSecretCommands(program); registerSkillsCommands(program); registerTeamCommands(program); registerWorktreeCommands(program); +registerRuntimeCommands(program); registerEnvLabCommands(program); registerPluginCommands(program); diff --git a/doc/architecture/runner-cursor-capabilities.md b/doc/architecture/runner-cursor-capabilities.md new file mode 100644 index 00000000000..ac03eeb8a26 --- /dev/null +++ b/doc/architecture/runner-cursor-capabilities.md @@ -0,0 +1,552 @@ +# Cursor ACP capability inventory + +Current assembled candidate (2026-10-03): **Cursor profile v11 is unqualified**. +It ports snapshot `22c78242a4e0c2369fecf0c2dc4e7600fbad6706` onto mainline +`dd868ed125cd709506dd9b29fca640a44d580501`, preserving newer recovery and owned +warm agent-file handoff. The [readiness checklist](../plans/2026-10-03-cursor-production-readiness.md) +tracks current qualification; the historical results below retain their own identities. + +The public installation path is `paperclipai runtime setup cursor`. It explicitly +downloads the pinned CLI `2026.09.26-dd393fe`, applies the source-owned patch, and +verifies the complete runtime closure. npm installation does not download Cursor. +Ordinary provider packs include those assets, and Daytona image preparation uses +the same distribution. Supported targets are macOS ARM64/x64 and Linux x64. + +Configure a company secret and bind it in the agent environment as +`CURSOR_API_KEY` or `CURSOR_AUTH_TOKEN`. Select the model explicitly; native ACP +must acknowledge that exact model before execution. Missing assets, credentials, +model access, and entitlement failures are surfaced without selecting a substitute. +`acpxSessionMode` supports Agent (default), Plan, and Ask and is part of recovery +identity. Acceptance of a native plan succeeds the planning run while leaving the +task open awaiting explicit direction; acceptance alone never starts implementation. + +Paperclip semantic questions are the supported question path. The native +AskQuestion handler is defensive compatibility code and is not certified for this +release. Per-run dollar usage is unavailable. Partial counters remain diagnostic +observations with unknown semantics; they do not establish measured spend or an +enforceable per-run dollar bound. Qualification uses the separately authorized +account cap. Image-input delivery, detailed native diffs, and deeper child +transcripts remain follow-ups. + +Historical source candidate (2026-10-01): **Cursor profile v10 is unqualified**. +Native tool IDs containing C0, C1 or DEL control characters now use the same +bounded hash in permission details, passive evidence and the sidecar tool event. +Blank permission IDs are rejected because ACPX drops blank tool-event identity. +Other admitted IDs stay unchanged at this boundary; distinct IDs stay distinct. +Canonical tool execution then applies the existing Rust opaque-ID conversion. +For example, `tool/1` remains the native permission/evidence key while its +execution ID is the deterministic opaque hash. Lifecycle readers explicitly +convert between these keys; they must not compare them directly. Both-order +bridge tests cover this distinction, including 161-character IDs. The original ACP request and option identity +remain intact for response delivery. The declaration binds the identity helper, +permission adapter and evidence projector. Retained v9 sessions are incompatible. +The native distribution and its usage limits are unchanged. Deterministic tests +cover both permission/tool arrival orders and an unresolved real ACPX callback; +fresh local and Daytona qualification is still required. + +Historical source candidate (2026-09-30): **Cursor profile v9 is unqualified**. +Its declaration binds `paperclip-cursor-usage-v4`, all three newly verified native +closures and the shared ACPX patch that persists bounded diagnostic observations. +Every native counter receipt remains partial with unverified semantics; it cannot +satisfy token or dollar accounting. The exact model remains unpriced until a +verified rate is available. Prior v7 source, builds and paid observations below +are historical and do not qualify v9. Old v7/v8 sessions must be reopened. +The v9 collector marks missing, invalid or reused child run ordinals as +`child_run_attribution_unverified`. The offline proof executes the pinned +vendor child creation and reuse methods on all three platforms; child counter +aggregation semantics remain unverified. + +Historical qualification checkpoint (2026-09-30): **Cursor profile v9 remains unqualified.** Draft PR #14724 at `3adee6f3fc652d5f5ee40b2061a16cea0c7341f5` passes Apex 5/5 and all 51 check runs plus one context; one unchanged-head failed-job retry is recorded. It fixes future eval-notice preservation but does not regrade the retained strict protocol-accounting failure or establish native USD. The verified fixed $25 account-cycle cap resets October 28 and applies to account on-demand fees, not per-cell costs. The separate 15-test helper proposal is unintegrated and grants no launch authority. Profile v9 binds usage-v4 and three native closures; counters remain partial with unverified semantics. Local and Daytona qualification remain pending. + +A separate native-question attempt ended without a captured +`cursor/ask_question` callback, and the model reported that AskQuestion was +unavailable. [Public upstream review](https://forum.cursor.com/t/agent-acp-never-offers-the-askquestion-tool-so-cursor-ask-question-is-never-sent/172976) +attributed the reported symptom to server-side ACP session identification, +without identifying a missing initialize capability or a verified fix release. +Native AskQuestion therefore remains unverified for the pinned model/mode; this +does not establish universal absence or support in every mode. Semantic question +continuation is separate and does not qualify the native callback. Evidence is +retained at `runtime-9-8-10-preparation/cursor-question-public-review/root-findings.json` +(SHA-256 `2fc34c1942e85ab58577b7cfa8ccdeafe520cc7c539aa67a7eef5a474848ad73`). + +Historical v7 checkpoint (2026-09-30): **Cursor profile v7 remains unqualified**. The corrected local native-plan case on controller/Product source `3d21d375de2b6249d9f5322bf001ce0ce0e052aa` and native runtime `5b8e4454ef0bf12d0bb068c2e41d8c9df9356a1c` passed all 26 checks. Reject, feedback, revision-bound acceptance, browser reconnect, exact delivery and unchanged workspace bytes passed. The single planning run succeeded while the task remained In Progress with an explicit next-message wait; no implementation or task completion is claimed. Cleanup and full end integrity passed. Result SHA-256: `685609f01291db84f40d890ef562473b95065e86907bd964b656ad732f6dd97a`. The earlier controller40064 attempt remains failed. Controller fix `c58a8881f` uses the production schema/policy/contract hash envelope; native runtime/profile bytes are unchanged. That exact Cursor PR head has green CI and Greptile 5/5. All three platform builds exist, but the remaining current-profile local/Daytona cases are still required. Native AskQuestion availability remains unverified. Cursor on-demand usage remains $0 under the approved fixed $25 account cap; native per-run USD is unknown. See the [comparative capability report](runner-rich-acp-capabilities.md) for exact source roles, evidence and remaining gates. + +Evidence updated: 2026-09-29. Candidate: `2026.09.26-dd393fe`. This profile is **not live-qualified**. One authorized task-context prompt on the initial free account returned an upgrade requirement, invoked no semantic tools, and supplied no usage receipt. Its cost is unknown; the account dashboard remained unchanged at coarse precision. The user then selected another account, whose exact-model local Runner Eval passed task-context and history tools with clean terminal settlement. Its dashboard attributed 56K rounded tokens to included usage and no incremental charge; ACP still supplied no token or dollar receipt. The local Product hello subsequently passed through the real browser, server, database, native runner, and authenticated completion tool. A second local Product case passed file creation, editing, command validation, an independent exact-byte matcher, and visible workspace artifact presentation. The merged-source local Product question and revision-bound Plan approval also passed browser response and semantic-tool continuation. A serialized follow-up passed pending semantic-question recovery across a server restart. A verified native ACP probe also denied a shell write before any observed side effect. Native Cursor questions/plans and their recovery, durable permission presentation, interruption, and Daytona execution remain required qualification gates. + +The earlier 2026-09-29 source used **profile v5**, with native session-mode admission and the instruction correction described below. All prior paid results (including v4) and packaged builds remain historical and cannot qualify v5. The earlier mode-selection limitation below describes the pre-v5 implementation. + +The reference is the runner's Codex app-server integration and its closed thread-item inventory in `src/provider-events.ts`. Cursor ACP and its private extension methods are the transport; the legacy Cursor adapter is unchanged. + +## Evidence and packaging + +- [Cursor's ACP contract](https://cursor.com/docs/cli/acp) describes stdio JSON-RPC, sessions, permissions and five extensions. The pinned binary reveals additional capabilities and one material documentation discrepancy below. +- `packages/paperclip-runner/cursor-distributions.json` pins vendor archive and complete extracted execution-closure SHA256 digests for macOS ARM64/x64 and Linux x64. All three archives were downloaded and independently inventoried (446 files on macOS, 454 on Linux). A real macOS ARM64 installation passed the materializer's full closure check. +- The archive is a complete runtime: bundled `node`, `index.js`, dynamically loaded numbered JavaScript chunks, native addons, workers, and assets. Pinning only the launcher or one executable does not pin its execution dependencies. The materializer rejects links/special files and overwrites, and verifies every file against the pinned closure digest. +- `test/fixtures/cursor-acp/credential-free-probe.json` records an actual macOS ARM64 initialization and nonbillable method probes. `initialize` reports ACP v1, session load/list, HTTP/SSE MCP, image prompts, and opt-in subagent events. Audio and embedded context are false. `session/fork` and `session/resume` return method-not-found. Creating/listing a session requires authentication. +- Static evidence comes from `5672.index.js` (ACP implementation), `8096.index.js` (ACP SDK protocol schemas), `190.index.js` (hook paths) and `index.js` (CLI/configuration), all bound by the archive and closure pins. SDK schema support alone is not counted as implemented harness capability. + +## Capability matrix against Codex app-server + +“Implemented” below means a tested Cursor implementation wired through the shared runtime, canonical activity channel and durable interaction path. It is not a claim of authenticated end-to-end qualification. + +| Capability | Cursor harness/ACP evidence | Paperclip treatment | Remaining gap or qualification | +|---|---|---|---| +| Streaming assistant/reasoning | `agent_message_chunk`, `agent_thought_chunk` in live and replay presenters | Existing ACP streaming path | Authenticated event capture required | +| Tool lifecycle/input/output | `tool_call` and `tool_call_update`, including raw input/output, locations and content | Canonical lifecycle, bounded output, input-changed indicator and one relative target; Cursor extensions add semantic activity | Raw input body, structured content/diffs/media and additional locations are not projected. See the exact field audit below | +| Permissions | Native `session/request_permission` offers `allow_once`, `allow_always`, and `reject_once` | Shared durable approval mapping preserves supported decisions; indefinite grant is not exposed without proven scope | A live raw ACP write denial preserved request ID `0` and original `reject-once`, with 98 absent-marker samples through cleanup. This does not qualify durable approval presentation or reconnect. The original grader failure and separate offline correlation assessment are retained | +| Human questions | `cursor/ask_question`, native single/multiple selections | `normalizeCursorQuestionRequest`: all questions required; opaque UI IDs round-trip native IDs; forged/duplicate/missing selections rejected | Active-turn adapter maps canonical answers, decline to skipped, and cancellation; durable delivery still requires end-to-end evidence | +| Proposed plans and decisions | `cursor/create_plan` with full Markdown, overview, todos, named phases and project flag | `normalizeCursorPlanRequest`: complete presentation; accept/reject/cancel; revision-derived question ID; rejection feedback | Foundation question descriptions allow 100,000 chars; this boundary also enforces the 196 KiB UTF-8 durable payload cap. Oversize rejects; it is never truncated. No generated `planUri`, since no uploaded plan artifact is fabricated | +| Execution checklist | Standard ACP `plan` and `cursor/update_todos` (`merge`) | Per-turn reducer merges by native ID and emits complete snapshots with increasing revisions | Native cancelled status is visible as “Cancelled” with canonical blocked status, since canonical plan schema has no cancelled enum | +| Child activity | Optional `subagent_spawned`, `subagent_state_update`; metadata has tool-call/agent/model identity | Stateful child normalizer emits canonical delegation and retains role/task across state deltas; active-turn adapter validates parent, known child spawn, and immutable tool/agent origin | Requires client `_meta.subagents: true`. ACPX SDK 1.4.0 lacks these two update variants. A closed opt-in pre-parser intercept binds the parent and declared descendants; child text/reasoning/plan/tool summaries are attributed to their child, never flattened into the parent. Nested media and raw tool details remain partial | +| Completed subagent tasks | `cursor/task`: description, prompt, subtype, model, ID, duration | Canonical delegation with role, model, task and duration in activity summary | Dedicated numeric duration field absent from canonical delegation schema | +| Subagent questions | Pinned ACP subagent handler explicitly rejects interactive questions | No unsupported affordance advertised | Confirmed harness omission; requires upstream support | +| Generated images | `cursor/generate_image`: description, path and reference-image paths | Canonical generated/viewed artifact events; description notice; realpath/regular-file containment checks; `registered:false` | Paths are references, not automatic uploads. Revalidate before artifact ingestion. No image bytes invented | +| Files and diffs | Tool `content` includes `diff` blocks with `path`, `oldText`, `newText`; native edit output and locations | Tool lifecycle plus the runner’s separate workspace-change evidence | Provider-reported before/after blocks are preserved by ACPX but not consumed by the canonical mapper. Additional and absolute locations are dropped; structured provider-diff projection needs implementation and live proof | +| True active-turn steering | No `session/steer`; `handlePrompt` cancels its existing pending prompt before starting another | Advertise steering unsupported | Concurrent prompt is interruption/replacement, not Codex-style in-place steering. Do not mislabel it | +| Queued follow-ups | No harness queue method discovered | Control-plane scheduling can send another prompt after settlement | No native queue-management events; do not send concurrently to emulate queueing | +| Cancellation | `session/cancel` calls the active prompt cancellation callback | Existing interruption path | Process/child cleanup and exact terminal settlement require live qualification | +| Turn settlement | Prompt implementation drains background subagent completion and awaits child publishers | Existing prompt result terminal boundary; explicit owned process cleanup | Native write denial settled without side effects. Background shell/child error settlement remains unverified. Credential-free stdin EOF did not exit within five seconds; do not advertise clean EOF | +| Session continuity | `session/new`, `session/load`; load replays historical messages, reasoning, tools, images and children | Existing session load/recovery boundary with identity fencing | Duplicate replay suppression and provider-death recovery need authenticated proof | +| Session discovery | `session/list` implemented, cwd filter absolute; pagination cursor rejected | Discovered and reported | Currently unused: runner recovers only its exact recorded session; arbitrary history browsing needs company-scoped discovery API | +| Fork/resume methods | `session/fork` and `session/resume` absent in agent implementation and return method-not-found | Unsupported | Session load is available; do not infer fork from SDK schema | +| Modes | `agent`, `plan`, `ask`; `session/set_config_option` returns exact mode; native mode updates are observable | v5 admits an explicit selected mode, binds recovery identity, and gates every prompt against native acknowledgement | Default Agent; mode is independent of permission policy. Plan acceptance does not switch to Agent. Native callback availability and Plan-mode completion remain unqualified | +| Model selection | `session/set_model`, config option `model`, model-parameter options, `config_option_update`; `cursor/list_available_models` extension | Exact requested model must be selected/verified by shared admission | Authenticated listing and exact Luna variant echo passed. Native parameterized picker metadata and separate parameter changes also passed; this richer configuration path is not exposed by the runner. See the authenticated discovery evidence below | +| Available commands | `available_commands_update`, including skills/slash commands | ACPX persists command metadata; runner canonical mapper deliberately emits no display event | Command picker and bounded command-discovery surface are not implemented | +| Session metadata | `session_info_update` title after automatic naming | Capability documented | Runner owns normalized session identity; provider title is currently not surfaced | +| Prompt media | Image=true, audio=false, embeddedContext=false in observed initialize | The shared runner turn contract accepts text only; it does not forward image attachment blocks to ACP | P1: implement bounded, validated attachment-to-ACP conversion with capability checks, then qualify the exact model. Audio/embedded context are explicitly unsupported by this advertisement | +| MCP and semantic tools | Session MCP injection supports stdio/HTTP/SSE; user/project config also loaded | Runner-owned authenticated MCP bridge only; the owned distribution patch never initializes ambient MCP | Exact tool discovery and company/token boundary remain live tests | +| Usage/cost | Vendor ACP emits only prompt `stopReason`; native `TurnEndedUpdate` separately exposes optional input/output/cache/reasoning counters | The observation-only patch below preserves bounded native counters as partial diagnostic metadata; it emits no standard usage or dollar receipt | Native counter aggregation semantics and exact-model pricing remain unverified; accounting stays unavailable | +| Reviews, compaction, hooks, memory | Native mode changes, hooks present internally; no dedicated ACP counterparts to Codex context/hook/memory lifecycle found | No fabricated event families | Distinguish confirmed absence of ACP event mapping from unverified native internals | +| Goal controls / lineage | No ACP goal or fork lineage protocol found | Unsupported | No equivalent of Codex native goals/thread lineage | + +## Wire and isolation findings + +The observation-only usage candidate adds `_meta.paperclipCursorUsage` to a +prompt response. Its `paperclip.cursor.native-usage.v1` envelope contains a fresh +opaque prompt ID and separate native invocation/run observations. It allows at +most 64 observations, 64 invocation identities and 16 KiB of JSON. Only observed, +safe nonnegative numeric input/output/cache-read/cache-write/reasoning counters +are retained; absent or invalid fields are omitted. Every envelope remains +`completeness: "partial"` with `native_counter_semantics_unverified`, even when +all fields are present. There are no standard ACP usage fields, counter sums, +estimated dollars or billed dollars. A reused child ID has no native callback +generation, so its counters are omitted with `child_run_attribution_unverified`. +The `invocationId` is a generated per-prompt ordinal. `nativeRun` is likewise a +generated invocation ordinal for parents, and the vendor's child run ordinal +for children; neither is advertised as a native request ID. Finalization closes +all tracked invocations, marks any missing terminal observation and enforces the +15,744-byte emission limit after all reasons are present, trimming observations +explicitly. The declared 16 KiB limit includes a 640-byte reserve for the bounded +ACPX request/message identity wrapper persisted alongside the envelope. +History and callbacks arriving after their invocation/prompt closes cannot add +observations to another prompt. Cancelled prompts return their own partial +envelope after the existing cancellation handling; they do not claim complete +cancelled-work accounting. A thrown JSON-RPC error returns no prompt envelope, +so these observations are not retained on that error path. This is a new source candidate, not a change to +the frozen v7 runtime or a regrading of historical evidence. + +The `paperclip-cursor-usage-v4` candidate retains the vendor version and archive +pins. Private full-tree materialization verifies 446 files per macOS target and +454 files on Linux; only the ACP implementation chunk differs from each vendor +tree. Its patched closures are: + +| Platform | Patched closure SHA-256 | +| --- | --- | +| macOS ARM64 | `257424bd48e35412091c6adfc61e4648e836757ec1d240d890bba81a24918c30` | +| macOS x64 | `6f28c799c5afdc64fbdff8a2157f565f17ae7615efe014ac389d63bf70cf2be2` | +| Linux x64 | `eadb8bb8ffb0450455b15b88c9b230307a9e149958a0c452d16dd157b4633d74` | + +`usage-v4-runtime-patch-offline-proof.json` retains fresh isolation/instruction +checks, and `native-usage-v4-offline-proof.json` records the usage checks. Earlier +proof files remain historical. These are offline source proofs; native emission +semantics, accounting completeness and live qualification remain unproven. + +The pinned-source offline harness executes the patched prompt method, native +invocation expression, child construction/reuse and child-update methods on all three platform chunks with +transport/session dependency doubles. It proves routing and bounds, not actual +backend emissions, retry/subagent inclusion, cache/reasoning overlap or billing. +Shared metadata persistence and new distribution/profile identities are separate +integration requirements. Until semantics and the exact selected model's pricing +are verified, these observations grant no accounting coverage. The vendor package +declares no license grant; its bundled license notices describe third-party +components. [Cursor's terms](https://cursor.com/en-US/terms-of-service) and the +applicable agreement remain release-review evidence for modified distributions; +this source work does not establish redistribution permission. + +The documentation calls todo/task/image methods notifications. The pinned presenter actually calls `connection.extMethod(...).catch(...)`: these messages can carry JSON-RPC IDs. They need immediate acknowledgement plus activity normalization. They must never become human input requests or block awaiting an unnecessary decision. + +Question and plan requests lack a session ID. The shared hook binds them to the retained connection, active execution/session/turn and originating request ID; it rejects stale/duplicate answers, cancels on turn termination, persists requests before exposing them, and awaits exact JSON-RPC pipe-write delivery before recording a resolved interaction. This proves transport handoff, not an additional provider application-level acknowledgement. The provider module does not substitute a best-guess session. + +Fixed launch arguments are `--disable-project-configs --disable-auto-update acp`, using the verified bundled Node and absolute verified `index.js`. Private HOME/XDG roots are required together with CURSOR_CONFIG_DIR, CURSOR_DATA_DIR, disabled compilation caching, `AGENT_CLI_CREDENTIAL_STORE=memory`, and `NO_OPEN_BROWSER=1`. Only explicitly bound CURSOR_API_KEY or CURSOR_AUTH_TOKEN may enter. The controller creates a provider/session-scoped credential-name binding from the explicit task environment, ignoring inherited or caller-supplied markers. Rust forwards it through the closed sidecar environment boundary. Before host admission, the sidecar rejects missing, stale, wrong-provider or unbound credentials and removes the marker before provider launch. Tests cover this full boundary and preserve legacy credential behavior. Do not use `--force`, `--trust`, or `--approve-mcps` to paper over governance. + +`--disable-project-configs` only suppresses `.cursor/cli.json`. It does not suppress project MCP, Cursor/Claude hooks, or installed plugins. `assertCursorWorkspacePolicy` refuses ambient project execution config from the workspace through its nearest Git root, including symlinks and unreadable configuration. Ordinary Claude settings with neither hooks nor plugins remain admissible. Enterprise system hooks are checked too. The host repeats admission checks before native launch. The vendor admission check alone cannot prevent a concurrent file mutation. The current `paperclip-cursor-usage-v4` distribution patch removes local hook loading, asynchronous team hook synchronization, ambient MCP loader initialization, and ambient-client merging from the ACP source. The retained admission check remains defense in depth. This closes those discovery paths independently of filesystem polling; authenticated validation on the patched final runtime remains required. + +Artifact paths are never automatically read or uploaded. References require a present regular file under the physical workspace, reject traversal and symbolic links, and retain `registered:false`. Private/outside paths are omitted and produce a visible warning. This is metadata validation, not durable file ownership or a replacement for artifact registration checks. + +## Verification and open work + +Passed in the provider worktree: + +- 17 Cursor Vitest cases: questions, multi-selection, native ID round-trip, invalid answers, exact plan revisions, outcomes, todo delta merging, delegation metadata, path containment/symlink refusal, child identity retention, private launch defaults and project config gates; active-turn canonical response mapping and immediate acknowledgements; stale/foreign/unknown child identity rejection. +- 5 materializer Node tests: platform pins, entire-tree tampering, linked paths, archive tampering before extraction, and overwrite refusal. +- Targeted strict TypeScript compilation for both provider modules and their imported contracts. +- Credential-free wire initialization and auth/method probes, plus real macOS ARM64 materialization and archive/closure inventories for the other two platforms. + +The generic native-distribution lease now snapshots the pinned closure, launches Cursor through its verified bundled Node with a closed-module guard, and uses the existing lifetime guardian. A real nonbillable Cursor initialization passed through that lease. Its six focused tests passed, including argument fencing, external module rejection, and native child ownership/exit proof. The existing snapshot suite also passed. The foundation’s later master integration refreshes the common Claude/Codex pins; the candidate pack below was built against that synchronized graph. + +Priority follow-ups before advertising support: (1) per-attempt cost attribution despite missing native usage receipts; (2) durable restrictive-approval and interruption proof; (3) remaining local and Daytona files/artifacts, questions/plans, recovery and settlement proof; (4) parent tool content/diff projection and richer nested child presentation; (5) authenticated trace audit for additional unconsumed native fields. Shared receipt/recovery coverage now proves pipe delivery, provider-loss expiry, exact lifecycle IDs and no approval replay. + +Remaining rich capabilities intentionally unused: session discovery, generic mode/config-option UI, provider session titles, command/skill picker metadata, full nested child transcripts beyond bounded delegation summaries, native numeric task duration, and optional planUri. They are listed above with reasons; neither “unsupported” nor “complete” should be inferred for unverified live behaviors. The final combined report must update this inventory with integration evidence and include any further events observed during authenticated qualification. + +Shared display-channel verification additionally covers exact rich-event schema validation, terminal/semantic/source injection rejection, active-turn fences, complete Unicode plan preservation through durable outbox persistence, secret redaction disclosure, and oversized UTF-8 rejection. The 4 KiB diagnostic preview cap remains active for diagnostics, never full approval documents. + +## Child transcript boundary and remaining fields + +Pinned source creates a separate session-update presenter for every child and can use another child as the immediate parent. Enabling `_meta.subagents` therefore requires handling both lifecycle and ordinary child session updates. The Cursor-specific ACPX compatibility path tracks declared descendants per stream and active prompt, forwards them only under the retained root parent authority, and suppresses unknown child sessions. It leaves the SDK's standard parent update schema closed. Known child updates never become parent assistant messages. Old-stream, foreign-parent, pre-turn, settled, aborted, oversized, unknown-child and duplicate-spawn cases are covered. + +Child assistant/reasoning text, plan steps and tool lifecycle are displayed in each child's activity summary. The 4,000-character activity surface retains a clearly marked tail when it fills; this is a summary, not a complete nested transcript. Child tool raw input/output, locations, diffs, media content and other child update fields are not rendered by this summary surface and produce explicit partial-detail notices. Adding canonical child transcript/tool surfaces is the next priority for these meaningful exposed fields. Native `capabilities:{}` currently carries no populated child capability fields; `_meta.cursor.agentId` and `toolCallId` are retained internally for immutable origin checks. Nested parent identity is represented in activity metadata because canonical delegation has no parent-child relation field. Native `disconnected` now yields a failed child with a visible explanation, never a successful completion. + +Three real ACP stream tests prove child lifecycle/transcript preservation before SDK parsing, nested parent attribution, standard parent parsing, and old-stream/turn/connection fences. Shared extension, reply-delivery and Pi receipt package cases pass against the resulting patched package; the current totals are recorded below. Three native factory cases verify all platform pins, exact profiles, and rejection of unsupported distributions; native assets resolve only from the runner-owned package authority. + +## Integrated pack and offline launch evidence + +The full `build-provider-pack.mjs --candidate-providers=cursor` path passed on source `25fb1b5b317e52a8ad50208d7681a1ee34bd939c` (foundation includes upstream master `18e8c121d`). It used standalone Node, pnpm 9.15.4, a fresh production deployment and the pinned vendor archive. The build checked portable Node relocation, fresh ACPX import and complete Cursor materialization. The retained manifest is `packages/paperclip-runner/test/fixtures/cursor-acp/provider-pack-darwin-arm64.json`; it contains only relative paths and digests. + +- Pack digest: `sha256:38bc6dd39c13b0f5478a1018e26deb61b0333ab0242f3d857e3c79d3a34b7682`. +- Cursor declaration: `sha256:c91aa592ec867071ec4457b9b7230399ce42ad918787ceed99b4b651ea5607e2`. +- macOS ARM64 closure: `sha256:77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25`. +- Resolved dependency lock: `sha256:9eea60187c6c808efb4d936a234a8d8000beac45253091abd4c5348132bd1408`, also the reviewed Daytona build digest for this branch. This digest was resolved from the clean tracked lock with the exact Docker resolution-only command. A second resolution from the clean tracked lock produced the identical digest; the frozen install applied the Cursor/Grok combined patch successfully. The image consumes the reviewed resolved-lock artifact without registry resolution. Image creation rejects a different digest before execution. + +`provider-pack-offline-proof.json` records a real launch through the generic profile installation registry from the pack’s verified native closure and retained snapshot, using private directories and no credentials. ACP v1 initialize succeeds. The correct `cursor/list_available_models`, `session/list` and `session/new` methods return authentication-required; `session/fork` and `session/resume` return method-not-found. The process produced no stderr, was explicitly terminated after these probes, and its lease closed. The earlier discovery fixture’s `_cursor/list_available_models` probe used an incorrect method prefix; this later probe corrects that uncertainty without changing the original evidence. + +A separate credential-free macOS probe on the same final pack initialized with JSON-RPC request ID `0`, then closed stdin at 20:56:57.776 UTC. The native process did not exit within five seconds and produced no stderr. The probe then sent the owned process group SIGTERM and closed its native lease. `provider-pack-offline-proof.json` retains `eofSettled:false`. This is a native lifecycle limitation: no clean-EOF guarantee is claimed. Production cleanup uses explicit process ownership and termination; Linux image qualification must separately record EOF behavior and verify bounded explicit cleanup. This probe sent no credentials or model prompts. + +At the prior foundation checkpoint `7721662f2`, the TypeScript/verified-sidecar build and all 177 targeted cases passed: Cursor normalization/policy/artifacts, credential bindings, the durable launcher's actual process specification, candidate backend admission, and sidecar behavior. The actual process-launch regressions verify explicit candidate credentials and their marker survive the final closed allowlist; ambient credentials and caller-supplied markers cannot select themselves. The prior source `f09a9a3f8` passed all 47 CI jobs after one retry for runner shutdown and a wall-clock rate-limit case, plus Greptile 5/5; it also passed 29 installed ACPX/materializer Node cases and 6 Daytona image-content cases. These prior results remain historical, distinct from the final source proof. + +The current pack/probe binds foundation `f063fbf2b`, including candidate admission, biller attribution, explicit unavailable usage, actual-spawn credential binding, and preservation of usage receipts on terminal provider failure. The verified TypeScript build, 56 Cursor/sidecar/eval-provider tests, 67 shared live/eval tests, and 25 mixed-usage contract tests passed during these qualification fixes. Earlier pack evidence remains in Git history. Its explicit offline factory model sentinel is not an authenticated model selection; no ACP prompt was sent. Daytona content identity includes the candidate selection, Cursor materializer and distribution manifest. This is local macOS ARM64 packaging/initialization evidence only. It does not qualify Linux execution, a Daytona image build/run, authenticated model work, permissions, or dollar accounting. No screenshot is claimed for these headless tests. That offline probe submitted no prompts and incurred no model usage. Subsequent authenticated attempts are recorded separately below. + +The final pack passed 31 installed-package/materializer contract tests, 82 Cursor/integrity profile tests (six platform-specific skips), and 12 candidate/image-content tests. TypeScript and verified-sidecar compilation passed. Its generic installation registry probe initialized the pinned native executable without credentials or prompts, then shut down cleanly. A fresh release daemon was built from source `78360fa55adff11da9f200b228467eca782b81e1`, Rust tree `17d327094be5759d80ef5bd02e15c59e78548ea2` (identical to foundation `f063fbf2b`), SHA256 `e6a9fb5170b76a49b8411834b3706e8edf8f1a1ae85ad13b55368158aa7f67a0`. This source includes bounded snapshot copying and public Grok packaging. Earlier paid attempts retain their original sources, package identities, and daemon digests; refreshing packaging does not rerun or extend their qualification. + +The locally built Linux x64 image also passed credential-free native initialization through the generic installation registry and immutable lease, with Docker networking disabled. Image ID is `sha256:5fa7951d1d6dd99305555fe00a5baf2bf5b834d16f021053737301975b93986f`, built from provider source `25fb1b5b317e52a8ad50208d7681a1ee34bd939c`. The Linux pack digest is `sha256:5fab4bc1f0633561b7d7e858a535ebd23faeccedb5f86044df071a3206d949ba`; the runner daemon SHA256 is `5eb3031aed118112caf0b80ea67a6cd01283098e2b4e526ef02d1416d125a1fb`. The pinned Cursor closure is `sha256:bf04ef8ea6a63191067a6b3e15139aa2c793d8419daab246aa3f0a012e1bbcd9`. ACP initialize request ID `0` returned protocol version 1. Linux also reported `eofSettled:false`; bounded explicit process-group termination and lease cleanup passed. `linux-image-offline-proof.json` retains the identities and limits. This local container proof sent no credentials or model prompts and is not a paid Daytona run or authenticated Linux qualification. + +## Authenticated discovery evidence + +`test/fixtures/cursor-acp/authenticated-capability-probe.json` records the user-authorized follow-up on 2026-09-28. The pinned verified distribution returned 41 model entries, created a session, accepted `gpt-5.6-luna[context=272k,reasoning=medium,fast=false]` through both `session/set_model` and `session/set_config_option`, and returned that exact value. No model prompt was sent. These facts establish authentication and model selection, not inference entitlement or full runner qualification. + +The native picker has two materially different interfaces. Ordinary ACP advertises fixed variant IDs. It rejected `grok-4.7[context=256k,reasoning_effort=low,fast=false]`, although each individual parameter value is advertised by the separate catalog. Advertising client `_meta.parameterizedModelPicker:true` enables base model `grok-4.7` and separate `session/set_config_option` calls; context `256k`, `reasoning_effort:low`, and `fast:false` all succeeded and were echoed. The runner does not yet negotiate or project this parameterized configuration path. Follow-up P2: bind the model plus complete verified parameter map to profile/recovery identity and expose supported options without silently choosing a default. In particular, this account's Grok and Composer catalog defaults enable the more expensive Fast option. + +Credential setup uses [Cursor's documented browser login](https://cursor.com/docs/cli/reference/authentication), with an isolated task-owned home and the pinned file credential store. Only the access token was explicitly saved in the operator's private secret file; credentials and refresh tokens are absent from retained public fixtures. The [official usage guide](https://cursor.com/help/account-and-billing/overages) distinguishes included usage from on-demand charges and warns that spend-limit enforcement is delayed. The first prompt therefore requires a separately reserved budget and attribution from the account usage records; missing ACP usage remains unknown, never zero-cost inference. + +## Authenticated qualification attempts + +`test/fixtures/cursor-acp/authenticated-qualification-attempts.json` retains sanitized per-attempt source, package, daemon, model, timing, cleanup, and cost evidence. The first canonical `get-task-context` attempt used pack source `e5159fb67ad28bf6a691bfa64ffddb332d96f8be`, exact Luna selection, one turn, a 60-second limit, a $0.50 declared envelope inside a $2 reservation, and zero retries. This is a Runner Eval against the mock control plane, not Product E2E or restrictive-permission qualification. + +The provider returned only an upgrade requirement and then normal completion. Source inspection of pinned `5672.index.js` confirms that actionable authentication/entitlement errors are rendered as ordinary assistant text and lose their typed failure before ACP settlement. No text-matching terminal heuristic is used. This was a native-wrapper gap in that unmodified candidate. The owned distribution patch described below preserves those typed errors as failed ACP RPC responses; the historical attempt is unchanged. The semantic oracle must still fail a response that did not invoke the required tool. + +The replacement-account attempt passed all four canonical `get-task-context` checks at source `01959b8a602683f13706807983f02c3cba9d36a0`, using newly rebuilt daemon source `f77ae83aeb6d802c73f1c955760017ae272eba29`. Both `get_task_context` and `get_task_history` returned successful authenticated semantic results. It took 29.291 seconds and exited cleanly. The Usage page showed a new `gpt-5.6-luna-medium` row at 19:28:55 UTC with 56K tokens, marked Included. Incremental cash was zero for that row; metered dollars remain unavailable. A conservative $0.07 list-price bound assumes at most 57K rounded tokens at the maximum listed $1.20/M rate and is not an invoice amount. There was no retry. + +The local Product hello first stopped at agent creation with HTTP 422 before any provider prompt. Foundation `26dde3cfe` fixed exact host-owned candidate/model admission at the public agent API. The separately authorized replacement passed at source `edf538e61e712dddb6b4d59045c3dcfd445686c7`, from 19:41:56.376 to 19:42:38.761 UTC. All six independent matchers passed: one exact completion marker, task Done, run succeeded, native mode, and local execution. The retained run confirms the 120-second configured deadline, no timeout, committed finalization, and clean cleanup. The final screenshot was inspected and shows the Cursor assignee, tool activity, one completion marker, and Done status. Native usage is null. The account Usage page attributes a new 19:42:25 UTC Luna row with 41.7K tokens, Included in Pro Plus, and zero incremental cash. Metered dollars remain unavailable; a conservative $0.06 list-price bound uses at most 42K rounded tokens at $1.20/M and is not a billing receipt. This proves the local completion-tool product path, not native question/plan, permission, file, recovery, or Daytona qualification. Both attempts remain in the sanitized evidence fixture. + +The local file-edit Product case passed at source `fe132224c2b30a8d9ce7b46cea38b8760af233fc`, from 19:44:43.582 to 19:45:24.406 UTC. All seven matchers passed, including an independent exact-byte read of the 24-byte final file. A native shell command created then rewrote the file and asserted the expected bytes; tool lifecycle and output were retained. The inspected browser screenshot shows the file, a downloadable workspace artifact card, edited-file activity, the completion marker, and Done. Cleanup passed. This is workspace artifact discovery and presentation evidence, not provider-reported diff projection. The account Usage page attributes a 19:44:59 UTC Luna row with 84.1K tokens, Included in Pro Plus, and zero incremental cash. Metered dollars remain unknown; the conservative list-price bound is $0.102, not an invoice. The trace also confirms a meaningful remaining field gap: native command `rawOutput.exitCode:0` survives in serialized output, while canonical `exitCode` remains null; extracting only validated command results is a P2 follow-up. + +The merged-source local question Product case passed at `a7e01a0cec397dd5048f5d5b5825658dc6e91450`, from 20:04:44.147 to 20:05:52.691 UTC. Its two expected provider runs submitted at 20:05:00.738 and 20:05:30.525 UTC, with no retry. The real `request_human_input` semantic tool produced a pending single-choice question, the browser selected Cobalt through the public response endpoint, the answered interaction woke the assignee, and the task completed with one final marker. Both pending and final screenshots were inspected; all six terminal matchers, lifecycle invariants, and cleanup passed. Bounds were 120 seconds per turn, 300 seconds overall, and a $2 reservation. Native usage is unavailable. The dashboard attributes Included Luna rows at 20:05:01 UTC (35.5K tokens) and 20:05:35 UTC (42K tokens), with zero incremental cash; metered dollars remain unavailable. A conservative $0.10 list-price allowance is not an invoice. This is semantic interaction and continuation evidence, explicitly not qualification of native `cursor/ask_question`, server restart recovery, or plan decisions. + +The local Product Plan case also passed on the same frozen source `a7e01a0cec`: 20:09:56.429–20:10:49.114 UTC, two expected turns, six passing terminal matchers and no invariant failures. The inspected UI displayed the complete two-step Plan at revision 1. The pending confirmation's `target.revisionId` exactly equaled the displayed document's `latestRevisionId`; it was accepted before continuation and successful completion. Both account Usage rows (48.5K and 43.7K) were Included with zero incremental cash; native metered dollars remain unavailable. This covers semantic `write_document` plus `request_human_input` confirmation, not native `cursor/create_plan`. + +The first subsequent server-restart attempt **failed**; the later authorized retry is recorded below. Its first launcher attempt failed before any provider call because the task's temporary Unix socket path exceeded the macOS limit; the zero-prompt failure remains recorded. An explicitly authorized infrastructure replacement used a short canonical private path and produced the required pending question. During the requested restart, embedded PostgreSQL failed `semget` because the host semaphore limit was exhausted. No answer or continuation occurred. The original result remains failed (`transient_infrastructure`), including failed API cleanup verification; a separate process inspection found no surviving task-owned server, PostgreSQL, runner, or provider process. The dashboard attributes one 20:13:38 UTC Luna row with 23.7K tokens, Included in Pro Plus, zero incremental cash, and no continuation row; native metered dollars remain unavailable. Exact submission time was not recoverable from the post-failure API snapshot, so attribution uses the retained case window. No automatic retry was made. + +The explicitly authorized serialized restart retry passed on source `d35b83074a018537f5475568d7410e3b1d676789`, using the final pack and matching release daemon, from 20:42:13.120 to 20:43:10.985 UTC. All six matchers passed, no invariant failed, and cleanup passed; a separate process inspection confirmed no remaining task-owned processes. The real pending semantic question survived the server restart, was answered Cobalt through the browser, and produced one successful continuation and Done. Both screenshots were inspected. This establishes semantic interaction persistence and wake-assignee recovery, not restoration of a blocked native `cursor/ask_question` request. The dashboard attributes two Luna rows at 20:42:26 UTC (23.8K tokens) and 20:42:57 UTC (41.7K), both Included with zero incremental cash; the displayed current-account total is 458.1K across eleven requests. Native metered dollars remain unknown. Original launcher and PostgreSQL failures remain retained. + +A separate native AskQuestion sidecar probe on final pack source `25fb1b5b317e52a8ad50208d7681a1ee34bd939c` sent one prompt at 20:39:47.990 UTC, with no Paperclip semantic tools. It requested single and multiple selection through Cursor's native question tool. The provider instead replied “The native AskQuestion tool is unavailable in this session,” emitted zero native input requests, and ended normally. The qualification oracle correctly failed; session close passed. The dashboard attributes a 20:39:48 UTC row with 17.4K tokens as Included, with zero incremental cash and unknown native metered dollars. This is observed default-mode tool unavailability, not proof of absence in every Cursor mode. The pinned source implements `session/set_mode` for agent/plan/ask and native question/plan handlers; the runner currently has no explicit mode-selection command. Retained native initialization exposes modes and model settings but no native tool inventory or question/plan availability bit. The bundled `ask_question_all_modes:true` default does not prove the server-selected tool catalog. No native Plan prompt was attempted, and no native interaction pass is claimed. + +The restrictive native denial probe used the final verified pack and exact Luna model, with one prompt submitted at 20:52:07.462 UTC. Cursor emitted numeric request ID `0` for the shell write. Its offered IDs were `allow-once`, `allow-always`, and `reject-once`; the client selected the unchanged `reject-once` and acknowledged the pipe write at 20:52:10.919 UTC. The provider ended at 20:52:11.980 UTC. The forbidden marker was absent in all 98 independent samples, including before launch, at the terminal, five seconds later, and after process-group and lease cleanup at 20:52:16.996 UTC. This establishes the native harness denial behavior, not sidecar normalization, Rust durability, or Product approval UI. + +The original probe grader failed because it expected `rawInput` on the permission frame. That frame omits the arguments, while the preceding same-session `tool_call` carries the exact command under the identical native `toolCallId`. The original failed result remains immutable. A separate offline assessment correlates that exact command, session, tool identity, actual response and delivery acknowledgment with the original marker samples; it passes without another model call. Tests reject missing binding, a different tool ID, a different command, wrong kind, or a title alone. `native-denial-proof.json` retains this distinction and sanitized wire frames. The account Usage page attributes 24.4K tokens at 20:52:07 UTC, Included in Pro Plus, bringing the displayed total to 482.5K across twelve requests with zero on-demand charge. Native metered usage remains unavailable. The conservative $0.03 list-price allowance is not an invoice. + +The first attempt also exposed the shared live-eval layer's rejection of missing usage. The foundation now retains explicit unavailable-usage records for diagnostic candidates, leaves the numeric receipt ledger empty, preserves the response for grading, and keeps unknown aggregate costs unknown across mixed turns. This fix does not retroactively turn the retained failed attempt into a pass. + +[Cursor's Grok Bot billing documentation](https://cursor.com/help/grok-bot/plans) says linking SuperGrok grants Grok Bot usage while leaving the Cursor plan unchanged. [Cursor's pricing page](https://cursor.com/pricing) lists Composer for free Hobby and frontier models for paid plans. These documents and model-list presence do not establish entitlement for a particular CLI prompt. The operator selected a replacement Pro+ account; its exact Luna configuration was echoed successfully, with no automatic model substitution. + +## Exact remaining field audit + +This source audit follows pinned native update → ACPX runtime → `canonicalProviderEventsFromAcpxRuntimeEvent` → Paperclip activity. It supplements the higher-level capability matrix and does not claim an authenticated trace was captured. + +| Native field/interface | Preserved boundary | Current projection / reason for partial use | Follow-up | +|---|---|---|---| +| Prompt image blocks | Pinned native initialize advertises image input | Shared `AcpxRuntimeTurnInput` has only `text`; `codex-runtime-adapter.ts` passes that text to ACPX, so no image blocks reach the wrapper from a runner turn | P1: add validated, bounded attachment inputs through the runner/ACPX contract, then prove exact-model behavior; native advertisement alone is not implementation evidence | +| Parent tool `rawInput` | ACPX forwards the body | Canonical tool schema exposes only `inputUpdated`; full native arguments are not displayed | P1: bounded/redacted tool-argument presentation with company and path checks | +| Parent tool `content[]` diff `path`, `oldText`, `newText` | ACPX forwards structured content; native permission requests also use it for proposed changes | Canonical tool mapper uses `rawOutput`, so completed provider-reported diff blocks have no dedicated output surface; separate workspace evidence is not equivalent | P1: validate physical workspace locations and expose attributed diff artifacts | +| Parent tool `content[]` images/resource blocks | ACPX forwards blocks and produces a summary | Structured media is unused by canonical tool output; safe registration/location semantics must precede ingestion | P1: validated artifact reference projection with explicit provenance | +| Parent tool `locations[]` | ACPX forwards bounded locations | Only first relative location becomes `target`; absolute paths and additional locations are discarded | P1: workspace-aware multi-file attribution; never strip arbitrary absolute prefixes | +| Parent tool `rawOutput` | ACPX forwards raw value | Canonical output is bounded serialized text, not lossless structured data; truncation is marked by output metadata | P2: typed tool-result detail viewer when useful | +| Plan step priority/native extras | ACPX plan entries | Display uses body/status and fixed ordinal step IDs; priority and unknown metadata have no canonical fields | P2: add explicit bounded fields if native traces show product value | +| Commands, config options, session title | ACPX persists/normalizes `available_commands_update`, `config_option_update`, `session_info_update` | Canonical display mapper explicitly skips these tags; exact model admission remains separate | P2: company-scoped command/config/title surfaces | +| Child tool raw arguments/results, locations, diffs and media | Cursor child intercept retains validated update for its adapter | Child activity keeps bounded attributed summaries, with visible partial-detail notices; there is no nested transcript/tool detail surface | P1: canonical child transcript and tool/artifact relation model | +| Child `_meta.cursor.agentId` / tool origin / parent ID | Retained for immutable-origin and descendant checks | Internal identity guards; parent relationship is textual metadata because canonical delegation lacks an edge field | P2: typed delegation lineage | +| Task numeric `durationMs`, optional accepted `planUri` | Native task/plan extensions | Duration is visible in summary; no numeric delegation field. No plan URI is returned because no plan artifact is registered | P2: typed duration; only return an actually registered plan URI | +| Session list, model parameters, generic mode control | Confirmed native ACP methods | No operator UI or cross-session discovery API in this integration; recovery loads only the runner-owned exact session | P2: explicit company-scoped controls; authenticated model catalog first | + +True steering, native queueing, fork/resume, native usage receipts and interactive child questions are absent from this pinned ACP implementation as described above. Their absence is distinct from the implemented-but-unused fields in this table. Remote team hooks, provider-native artifact traces, interruption settlement and authenticated Linux/Daytona behavior remain unverified, not confirmed absent. + +## Initial isolation patch, profile v3 (2026-09-29) + +The initial profile v3 materialized `paperclip-cursor-isolation-v1` on top of the +unchanged vendor `2026.09.26-dd393fe` archives. The materializer first verifies +the archive and **vendor** execution closure, requires exact single-occurrence +source anchors in an input-digest-pinned ACP chunk, applies the owned patch, +then verifies a separately pinned **patched** execution closure. Both identities +remain in the distribution manifest. Unknown, changed, already-patched, or +ambiguously matched source fails closed. The patch does not change the legacy +Cursor adapter or the vendor interactive CLI. + +The ACP shared-services initializer no longer initializes or loads its ambient +MCP loader. Each ACP session starts with an empty client lease and adds only the +explicit session MCP definitions, preserving native last-definition-wins behavior. +It never borrows an earlier or ambient lease, including when the session MCP list +is empty or invalid. Local hook config is an empty snapshot, and the remote team +hook fetch/install/update path cannot start. Newly created project config during +a session therefore cannot enter either removed discovery path. This is source +isolation, not an operating-system sandbox: explicitly allowed native shell +commands still have their granted filesystem/network powers. + +Typed `ActionRequiredError` instances now produce a failed JSON-RPC response +with data schema `paperclip.cursor.provider-error.v1`, kind `action_required`, +and the closed action enum `login`, `upgrade`, `payment`, `config`, or `unknown`. +Login and native `ConnectError` with the Unauthenticated code use ACP error +`-32000`; other action requirements use `-32603`. Provider-private error detail +is not copied into these responses. Ordinary assistant text is never examined to +infer an entitlement failure. Generic provider exceptions outside these typed +branches retain the vendor behavior and remain an upstream audit item. + +`qualify-cursor-runtime-patch.mjs` executes expressions extracted from each actual +input-digest-verified platform chunk. Its poisoned ambient loaders prove no +ambient access while owned MCP, empty sessions, and duplicate definitions work; +it also executes all typed error branches and ignores entitlement-shaped ordinary +errors. The entire patched chunks compile for macOS ARM64/x64 and Linux x64. +`runtime-patch-offline-proof.json` records these checks with zero provider calls. +All three authentic archives separately passed materialization and post-patch +closure verification. Seven materializer tests cover drift, ambiguous anchors, +closure tampering, links, archive tampering, identity consistency, and overwrites. +Both patched macOS binaries also returned ACP v1 from credential-free real-process +initialization and exited after explicit SIGTERM; x64 ran through the ARM host +compatibility layer. `runtime-patch-initialization.json` retains this distinction. +These are offline checks, not authenticated macOS x64 or Linux/Daytona qualification. + +Prior Product/Runner results above measured the earlier unpatched runtime. The +profile must retain pending qualification until the rebuilt final foundation +runtime passes the remaining native interaction, durable approval, cancellation, +recovery, accounting, and authenticated target-platform gates. + +### Frozen profile v3 build + +[The retained build record](../../packages/paperclip-runner/test/fixtures/cursor-acp/production-v3-offline-build.json) +identifies exact runtime source `526081b9d6c905de5e2da680e3a37c620899b380`, +profile v3, the ARM64 portable package and matching release daemon, both lock +hashes, the committed ACPX patch hash, and credential-free launch results. +Dependencies were installed into a fresh private store with copied files to +avoid borrowing mutable package bytes from another worktree. The initial frozen +install failed because the committed lock's patch configuration differed; that +failure is retained. A private derived staging lock then passed frozen install. +The repository lock was not changed. + +The package's real immutable lease passed ACP initialization, required +authentication for model/session discovery and creation, rejected unsupported +fork/resume methods, and closed with no stderr. The TypeScript and release daemon +builds passed. Twenty-six installed-package contracts passed. Another 184 control +tests passed, including two Cursor-specific tests using the real installed ACP +SDK stream: sessionless native single/multi-selection and complete revision-bound +plan replies preserve request ID zero, await response pipe delivery, reject stale +plan revisions, and suppress late answers after cancellation. The surrounding +suite covers mocked admission, permission identity, provider-loss expiry, +recovery fences, and cleanup ownership. These are deterministic tests, not live +native interaction, durable browser approval, or provider-death qualification. + +The new `cursor-runtime-patch.mjs` is present in the Docker build context. It is +also a required Daytona content-identity input: changing patch source must change +the image identity even when another declared input is unchanged. Publishing or +qualification must use an image whose content hash includes that script. + + +## Profile v4: instruction delivery and admission (2026-09-29) + +The pinned native ACP implementation ignores `_meta.systemPrompt` on session +creation, and ACPX does not send it on load. Native workspace rules do not read +Paperclip's registered `AGENT_HOME`. The hidden `--system-prompt` option is an +internal-team-gated interactive CLI feature and is not used by this integration. +Those facts invalidate any assumption that prior transport success alone proved +canonical governance or custom entry instructions reached Cursor. + +The owned `paperclip-cursor-instructions-v2` patch preserves the v3 isolation and +typed-error changes and adds the composed instructions through Cursor's native +`LocalResourceProvider.additionalRules` path. The sandbox supplies an ephemeral, +controller-owned `PAPERCLIP_CURSOR_INSTRUCTIONS` payload. Ambient environment +cannot select or replace it, and it is not persisted in ACPX's launch environment +record. The payload binds exact UTF-8 content (maximum 32 KiB, no NUL) to SHA-256; +it includes the already-composed governance, custom entry content and registered +agent-home context, without discovering another workspace instruction file. +Explicit empty content has an empty rules list and the SHA-256 of empty bytes. + +Native session creation and load both use this process-owned snapshot and return +`_meta.paperclipCursorInstructions` with schema, exact digest, and byte length. +The additional rule uses the native global-rule representation and retains +native ordering: additional rules precede the ordinarily discovered project +rules in `RequestContext.rules`. It is not prepended to the user's message and +does not replace Cursor's built-in system prompt. The synthetic rule identifier +is `paperclip://runtime/instructions`; no generated instruction file is exposed +to workspace discovery. + +A synchronous ACPX `protocolGuardFactory` checks each connection independently. +It correlates the acknowledgement with the exact new/load request and session, +rejects missing or mismatched acknowledgements before SDK dispatch, and blocks +outbound prompts until that session is admitted. Provider death and automatic +reload reset authority. The adapter also asserts admission immediately after +`ensureSession`. A best-effort observer cannot authorize a prompt. The shared +patch retains upstream terminal-failure diagnostics, rich interaction callbacks, +response-delivery receipts, and Cursor child-event handling. + +[The v4 declaration](../../packages/paperclip-runner/test/fixtures/cursor-acp/profile-v4-identity.json) +binds profile digest `sha256:b1440d559ebc4eef5c7a582f1c81fc153270cfbafa1731a8ee76d83713bdf61b` +to all platform closures and ACPX patch SHA-256 +`64180c194ab841d6f4bba7e6eca5ead621c6222c9a8057e4bb2bb6f007d8bb3c`. +Vendor archive and vendor closure hashes are unchanged. All three pinned archives +passed fresh materialization and the new patched closure check: + +| Platform | Patched closure SHA-256 | +|---|---| +| macOS ARM64 | `912a37edb67fd7737809c24049dce2db20b1330f98b15a6e43809f0e0943cb6c` | +| macOS x64 | `322f54c7bd533b202a311ce8e2c8916bfe0202790ef8a40fbb36bb89cbd506cb` | +| Linux x64 | `a375fc771dfe86b9b24532f95956ec4b02c5f4f24d80f5db06565160eb3b672c` | + +`qualify-cursor-instructions.mjs`, called by the existing patch qualifier, executes +all three digest-verified native resource constructors and bounded payload logic. +On ARM64 it additionally executes the exact patched new/load methods and native +cache/RequestContext construction with explicit dependency doubles, proving the +composed bytes and native ordering at that boundary. This is an offline source +execution proof, not a claim that an authenticated model obeyed the instructions. +[The retained proof](../../packages/paperclip-runner/test/fixtures/cursor-acp/instructions-v4-offline-proof.json) +and `runtime-patch-offline-proof.json` record the limits. + +Nine new deterministic tests cover exact UTF-8 and empty instructions, ambient +injection exclusion, recovery refresh, request/session/connection authority, +actual SDK new/load admission, and actual runtime automatic reload after provider +death. Valid acknowledgements permit the prompt; missing or wrong digests fail +before prompt bytes. Another 113 adapter/sandbox/identity tests, 25 fresh isolated +ACPX contracts, seven materializer cases, and the runner TypeScript compile pass. +No paid calls or billing mutations were made. A new final-source provider pack +and daemon, authenticated instruction semantics, and the remaining live gates +are still required before qualification. + +## Cursor profile v5: admitted native session modes (2026-09-29) + +The explicit `acpxSessionMode` configuration selects `agent` (the default), +`plan`, or `ask` for Cursor. The provider/sidecar field is `cursorMode`; it is +separate from ACPX's persistent/oneshot session lifecycle. Other providers reject +this field. Mode is included in the immutable session key and recovery identity; +a missing or changed mode cannot reopen an existing v5 session. + +Admission checks the pinned native `session/new` or `session/load` response's +mode configuration and mode state. If necessary, it sets the selected mode using +`session/set_config_option` and requires its exact echoed configuration before +admitting a prompt. A bare `session/set_mode` response or asynchronous +`current_mode_update` alone is not acknowledgement. Every new native connection +gets a fresh guard: a mismatched automatic reload blocks prompt delivery, and an +unsolicited active mode change fails closed. Explicit startup/reopen admission +can reassert the selected mode within the existing admission deadline and renews +the consumed launch lease after each temporary control connection. + +The pinned CLI maps Agent to native `default`, Plan to `plan`, and Ask to +`search`, and carries that metadata into the native UserMessage mode. The +inspectable `mode-v5-offline-proof.json` executes the pinned native methods with +storage, transport, and enum-converter doubles. Real ACPX fixture tests separately +prove cold-manager load/control ordering and automatic reconnect rejection before +prompt bytes. Neither is paid model/tool qualification. + +Native mode does not change Paperclip permission policy, company governance, or +filesystem isolation. The native descriptions say Plan is read-only planning and +Ask has no edits or command execution; these descriptions are not an OS sandbox +claim. Native ACP CreatePlan acceptance does not itself mutate session mode in +the pinned handler. An approved plan therefore stays in the selected Plan mode; +there is no implicit promotion to Agent mode. Actual native question/plan callback +availability, Plan-mode governed completion, and accepted/revised artifact flows +remain pending Product qualification. Existing v4 paid results and macOS x64 +Rosetta build evidence remain historical and do not qualify v5. + +[The v5 declaration](../../packages/paperclip-runner/test/fixtures/cursor-acp/profile-v5-identity.json) +binds digest `sha256:aa8c0b2b84786982bcd06b7634bf95be6f2bc42bb8def48a8751dc50cf739b6b`. +Profile v5 retains the native distribution closures and instruction patch. Its +canonical declaration adds the admitted mode policy and binds ACPX patch +`79aad2d688b03362e8cfcbf7a08f78a8383869f6882a9c9ed66f1d18efb94f2b`, which preserves +identified empty rich-input chunks at the native parser boundary. + +## Accepted native plans wait for explicit continuation (2026-09-30) + +With Cursor CLI `2026.09.26-dd393fe`, accepting native CreatePlan can finish the +planning turn normally without a Paperclip semantic completion result. The +controller recognizes this boundary only from the admitted Plan-mode run, +company/task scope, exact native plan request and accepted revision, acknowledged +human response delivery, and normal terminal event. An explicit semantic result +still takes precedence. + +The planning run succeeds while the task stays `in_progress`. Its durable comment +says: “Plan accepted. This task is waiting for your next message. This run used +Plan mode; no implementation or task completion is claimed.” Acceptance neither +changes the mode nor schedules implementation or an automatic follow-up. A user +can send a new task message to continue; that message follows ordinary admission +with the settings selected for the new run. + +The committed wait survives controller restart, agent pause, changes to settings +for future runs, and later profile-catalog revisions. Those changes are not +permission to start task work. New waits must match the current qualified +profile; recovery retains an older committed wait only while its original +admission, contract, native request/answer/terminal proof, accepted-result +identity, assignment, and applied task decision remain unchanged. A new user +message or superseding task decision/run ends that wait's authority. Altering the +original proof does not receive the historical-profile exception. + +Deterministic tests cover real result persistence and transactional finalization, +stale delivery rejection, restart/recovery without automatic wakes, catalog and +future-setting changes, ordinary user continuation, and semantic-finish priority. +The Product fixture preserves its native decision and workspace no-effect checks +and now expects a successful planning run with an unfinished task and visible +next-message guidance. Paid requalification of this controller behavior is still +pending; the retained earlier Product failure is not reclassified as a pass. +Runner, sidecar, provider distribution, profile, and image bytes are unchanged by +this controller settlement change. + + +### Cursor7 native plan lifecycle binding + +The retained ca702 Cursor6 local plan attempt delivered both native decisions, +including the revised plan's acceptance, but failed controller settlement. The +provider emitted the accepted `CreatePlan` tool's activity after the callback +request had been created. The earlier proof treated that activity as unrelated +work. Workspace snapshots remained unchanged; that failed attempt is preserved. + +Cursor7 binds the native callback's parent tool identity to the canonical +`runtime_request` item identity through both the sidecar and direct driver. The +controller requires exactly one successful lifecycle for that same tool, in the +same session and turn, completing after the accepted answer and before the normal +turn terminal. It rejects unrelated activity during this interval, failed or +missing lifecycle records, and altered durable proof. Tool names and plan titles +are not identity evidence. The committed receipt includes the correlated rows' +digest. Existing exact Cursor6 committed waits retain their historical contract; +Cursor6 cannot create a new wait or reopen under Cursor7 admission. + +Focused deterministic checks cover the actual observed event order, identity +normalization boundaries, cancellation/expiry reconstruction, unrelated tools, +and persisted historical waits and proof tampering. Current runtime/sidecar/pack builds are complete; Cursor7 paid qualification +remains blocked by the failed settlement case in the checkpoint above. This changes the +runtime projection and profile contract, while the pinned Cursor native +executable and distribution patch stay unchanged. + +### Runner Eval diagnostic retention + +The live-session recorder admits the bounded `cursor_native_usage_observed` +notice only for the current Cursor session and turn. It validates the fixed +provenance/reasons and generated observation identities, rejects unknown or +oversized fields, and preserves parent/child observations through durable eval +checkpoint reload. These unsummed, partial counters never enter the usage ledger +or establish native USD. Deterministic transport/store tests cover this capture +path; paid qualification remains pending. Earlier artifacts produced by the old +recorder cannot establish whether these native counters were emitted and must +retain their original accounting failure. diff --git a/doc/plans/2026-10-03-cursor-production-readiness.md b/doc/plans/2026-10-03-cursor-production-readiness.md new file mode 100644 index 00000000000..5debf8e89a2 --- /dev/null +++ b/doc/plans/2026-10-03-cursor-production-readiness.md @@ -0,0 +1,70 @@ +# Cursor production readiness — 2026-10-03 + +The release scope is Cursor CLI `2026.09.26-dd393fe` on macOS ARM64, macOS x64, +and Linux x64 (including Daytona). Qualification uses the explicitly selected +`gpt-5.6-luna[context=272k,reasoning=medium,fast=false]`. Native AskQuestion and +authoritative per-run USD accounting are excluded from certification. Semantic +Paperclip questions remain the supported question path. Unknown usage is unknown. + +## Source-to-port map + +| Source | Destination / decision | +| --- | --- | +| Mainline `dd868ed125cd709506dd9b29fca640a44d580501` | Branch `codex/cursor-production-readiness`; preserve its recovery, completion, and managed warm-directory ownership | +| Combined snapshot `22c78242a4e0c2369fecf0c2dc4e7600fbad6706` | Cursor installation, native isolation/instructions/modes, extensions, tool evidence and partial usage | +| Same snapshot, shared ACP transport | Permission identity, delivery acknowledgement, cancellation, canonical tool lifecycle and recovery-mode binding | +| Same snapshot, controller | Accepted-plan wait proof, status arbitration/commit/recovery, durable cancellation request ownership | +| Same snapshot, Product E2E | Cursor native interactions, active Stop, warm continuity, remote observers and owned cleanup | +| Mainline warm agent-files work | Retained instead of importing the older competing warm-copy implementation; extend its ACP applicability when qualified | +| New public installation work | CLI `runtime setup cursor`, bundled provisioner and default provider-pack assets | +| Pi/Copilot source and campaign | Excluded; existing pending providers retain mainline identities and admission gates | + +Historical proofs retain their original profile/build identities. In particular, +the v10 Stop result at source `22c78242` and the earlier plan/warm/Daytona completion +results do not certify this assembled candidate. Strict accounting failures are +preserved; semantic behavior is assessed separately. + +## Readiness checklist + +- [x] Create the branch from the agreed mainline base. +- [x] Port Cursor and necessary shared implementation without replacing newer controller files wholesale. +- [x] Complete targeted tests and make the consolidated branch buildable with admission disabled. +- [ ] Ship and verify explicit public runtime setup; npm lifecycle must not download Cursor. +- [ ] Include Cursor in ordinary provider-pack and Daytona image builds. +- [ ] Verify company secret bindings, exact model diagnostics and Agent/Plan/Ask configuration. +- [ ] Preserve successful accepted planning runs as open tasks awaiting explicit user direction. +- [ ] Show unavailable accounting explicitly and keep partial counters diagnostic-only. +- [ ] Freeze candidate source/profile/patch/pack/image identities and build all three platforms. +- [ ] Reconcile the remaining campaign budget; run paid cells serially within the existing account cap. +- [ ] Qualify normal setup/completion locally and on Daytona. +- [ ] Qualify file editing, independently checked bytes/validation and accessible artifacts on both targets. +- [ ] Qualify semantic questions/restart with exactly-once answer consumption on both targets. +- [ ] Qualify native plan reject/revise/accept/cancel and correct task/run states on both targets. +- [ ] Qualify denied writes and Stop during pending approval, including owned process retirement, on both targets. +- [ ] Qualify three warm turns with stable session/workspace/agent-files ownership and no duplicate output on both targets. +- [ ] Qualify provider loss, input expiry and actionable errors without mutation replay or false success. +- [ ] Run seven semantic Runner cases; retain strict accounting results separately. +- [ ] Promote Cursor consistently only after the candidate passes; leave other pending providers gated. +- [ ] Run contracts/replay, token gates, recursive typecheck, full tests and build. +- [ ] Repeat a clean normal-install smoke with qualification overrides absent. +- [ ] Deliver exact identities, capability limits and completed acceptance matrix; prepare focused template-based PR. + +Production merge/deployment is a separate final action. Rollback disables new +Cursor admission while preserving records, valid committed plan waits and recovery +inspection. + +## Consolidation verification + +The assembled workspace build and recursive typecheck pass. Focused Cursor +normalization/installation tests, controller settlement tests, CLI setup containment, +and 19 native ACP backend tests pass. The 65 protocol/package contract checks pass. +Full-suite failures remain retained for diagnosis; these narrow results are not a +production certification. All three pinned Cursor distribution closures were +materialized and verified afresh. macOS ARM64 ordinary provider-pack preparation +passed without candidate flags. + +The resolved campaign lockfile remains local because repository policy gives +GitHub Actions ownership of lockfile commits. Its SHA-256 is +`70af8ab3d7051c85fc1a55c11e9afe8887d9711232e3c6e97666006562217e5f`; +retain these exact resolved bytes with candidate artifacts and pass their digest +to the immutable image build. diff --git a/docker/daytona-runner/Dockerfile b/docker/daytona-runner/Dockerfile index 5a1da31b1f8..6cae25a5935 100644 --- a/docker/daytona-runner/Dockerfile +++ b/docker/daytona-runner/Dockerfile @@ -14,6 +14,11 @@ RUN cargo build --locked --release -j 2 -p paperclip-runner-core --bin paperclip FROM node:24.21.0-bookworm@sha256:5a750d3be5e5c80275f8c9a5367c3aed99c2875656590c8d0701c7ee687f5f0a AS provider-pack-build RUN corepack enable && corepack prepare pnpm@9.15.4 --activate +# Bound build-time memory on hosts that expose many CPUs but little RAM. +# These settings belong only to this build stage, never the runtime image. +ENV NODE_OPTIONS=--max-old-space-size=1024 \ + npm_config_child_concurrency=1 \ + npm_config_network_concurrency=4 WORKDIR /workspace COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc tsconfig.base.json ./ COPY patches ./patches @@ -28,15 +33,19 @@ COPY cli/package.json ./cli/package.json # The complete resolved lock (including transitive integrity hashes) is reviewed. # Reject registry-time drift BEFORE installing packages or running lifecycle code. # Refresh this digest together with source/provider dependency changes. -ARG PAPERCLIP_RUNNER_LOCK_SHA256=2c46a68811ba1d504a41b6f4d3f642bc93f4a1c615097754c9c6486881dba8e6 -RUN printf '%s pnpm-lock.yaml\n' "${PAPERCLIP_RUNNER_LOCK_SHA256}" > /tmp/provider-lock.sha256 \ +ARG PAPERCLIP_RUNNER_LOCK_SHA256=70af8ab3d7051c85fc1a55c11e9afe8887d9711232e3c6e97666006562217e5f +# pnpm 9 subtracts PNPM_WORKERS from available CPUs; it is not a worker count. +# Subtract all available CPUs to select its minimum (one tarball worker). +RUN export PNPM_WORKERS="$(node -p 'require("node:os").availableParallelism()')" \ + && printf '%s pnpm-lock.yaml\n' "${PAPERCLIP_RUNNER_LOCK_SHA256}" > /tmp/provider-lock.sha256 \ && sha256sum -c /tmp/provider-lock.sha256 \ && pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...' ARG PAPERCLIP_RUNNER_SOURCE_REVISION # Optional qualification assets; this never promotes candidate profiles. ARG PAPERCLIP_RUNNER_CANDIDATE_PROVIDERS= RUN test -n "${PAPERCLIP_RUNNER_SOURCE_REVISION}" -RUN pnpm --filter @paperclipai/paperclip-runner build:typescript \ +RUN export PNPM_WORKERS="$(node -p 'require("node:os").availableParallelism()')" \ + && pnpm --filter @paperclipai/paperclip-runner build:typescript \ && PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_RUNNER_SOURCE_REVISION}" \ node packages/paperclip-runner/scripts/build-provider-pack.mjs /provider-pack \ "--candidate-providers=${PAPERCLIP_RUNNER_CANDIDATE_PROVIDERS}" \ @@ -69,8 +78,8 @@ RUN /usr/bin/python3 -m venv /opt/hermes \ && /opt/hermes/bin/pip install --no-cache-dir hermes-agent==0.19.0 \ && ln -sf /opt/hermes/bin/hermes /usr/local/bin/hermes -ARG CURSOR_VERSION=2026.09.18-9a7762b -ARG CURSOR_SHA256_AMD64=b1308f5a2fc05458b9d8966752986bb23a971bbcc67c842c1df94c4b8132bad9 +ARG CURSOR_VERSION=2026.09.26-dd393fe +ARG CURSOR_SHA256_AMD64=8085fd120f5c71f4eae7fea26a043718e5644e3071e4fab3220a0e58c51f9593 RUN set -eu; \ arch="$(dpkg --print-architecture)"; \ [ "$arch" = "amd64" ] || { echo "FATAL: cursor pin only covers amd64, not $arch" >&2; exit 1; }; \ diff --git a/docker/daytona-runner/README.md b/docker/daytona-runner/README.md index cd54962638f..cfec6636655 100644 --- a/docker/daytona-runner/README.md +++ b/docker/daytona-runner/README.md @@ -1,5 +1,11 @@ # Paperclip Daytona runner image +Ordinary provider packs now include pinned Cursor assets for Linux x64; no +`--candidate-providers=cursor` flag is needed. Cursor admission remains disabled +until the [production readiness gates](../../doc/plans/2026-10-03-cursor-production-readiness.md) +pass. The public CLI installation command is `paperclipai runtime setup cursor`; +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.32, the compiled OpenCode proxy, ACPX 0.13.1 sidecar, qualified ACP diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index eb02fb4efc4..83ac24730a6 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -124,6 +124,7 @@ export { paperclipRunnerTransitionConfig, normalizeLegacyRunnerProvider, resolvePaperclipRunnerPermissionMode, + resolvePaperclipRunnerCursorMode, } from "./paperclip-runner-permissions.js"; export { PAPERCLIP_RUNNER_INGRESS_PORT, diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.ts b/packages/adapter-utils/src/paperclip-runner-permissions.ts index cb32c0fd78c..2fa8c9730a9 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.ts @@ -162,6 +162,21 @@ export function resolvePaperclipRunnerPermissionMode( : capability.defaultMode; } +/** Native session behavior is independent of tool permission policy. */ +export function resolvePaperclipRunnerCursorMode( + provider: unknown, + agent: unknown, + value: unknown, +): "agent" | "plan" | "ask" | undefined { + if (provider !== "acpx" || agent !== "cursor") { + if (value !== undefined) throw new Error("acpxSessionMode is supported only for Cursor"); + return undefined; + } + if (value === undefined) return "agent"; + if (value === "agent" || value === "plan" || value === "ask") return value; + throw new Error("Cursor session mode must be agent, plan, or ask"); +} + export function resolvePaperclipRunnerModel( provider: keyof typeof PAPERCLIP_RUNNER_DEFAULT_MODELS, value: unknown, diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index c11343cae4e..885c5338ad1 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -619,6 +619,8 @@ export interface PaperclipQuestion { helpText?: string; required: boolean; answerMode: "single_select" | "multi_select" | "text"; + /** Editable starting text, never an implicit or submitted answer. Text mode only. */ + initialText?: string; options?: PaperclipQuestionOption[]; customAnswer?: { enabled: true; label?: string; placeholder?: string }; textValidation?: { diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index eb381bb7890..1a48826c2cd 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -5,6 +5,8 @@ import { resolvePaperclipRunnerModel, resolvePaperclipRunnerIdleTimeoutMs, resolvePaperclipRunnerPermissionMode, + resolvePaperclipRunnerCursorMode, + PAPERCLIP_RUNNER_ACPX_PROFILES, type CreateConfigValues, } from "@paperclipai/adapter-utils"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "../index.js"; @@ -95,7 +97,8 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record profile.value === schemaValues.acpxAgent)?.value ?? "claude"; + const cursorMode = resolvePaperclipRunnerCursorMode(provider, acpxAgent, schemaValues.acpxSessionMode); const schemaModel = typeof schemaValues.model === "string" ? schemaValues.model.trim() @@ -103,6 +106,9 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record, request_id: String, question_set: Value, origin: Option, @@ -124,7 +125,32 @@ pub fn decode_acpx_event( validate_question_set(&question_set)?; let question_set = sanitize_question_set(question_set)?; let origin = optional_object(&event.payload, "origin", "input request origin")?; + let tool_call_id = if event.payload.get("toolCallId").is_some() { + let id = + required_id_with_limit(&event.payload, "toolCallId", "input parent tool", 240)?; + if id.len() > 240 + || sanitize_value(&json!(id)) != json!(id) + || origin + .as_ref() + .and_then(|v| v.get("provider")) + .and_then(Value::as_str) + != Some("cursor") + || origin + .as_ref() + .and_then(|v| v.get("method")) + .and_then(Value::as_str) + != Some("cursor/create_plan") + { + return Err(LocalRunnerError::invalid( + "ACPX input parent tool identity is invalid", + )); + } + Some(id) + } else { + None + }; Ok(AcpxEventPayload::InputRequested { + tool_call_id, request_id: required_id_with_limit( &event.payload, "requestId", @@ -380,7 +406,7 @@ fn sanitize_question_set(mut value: Value) -> Result { }; // IDs, answer modes, and validation patterns are protocol values: // changing them would break response correlation or semantics. - redact_object_text(question, &["header", "prompt", "helpText"]); + redact_object_text(question, &["header", "prompt", "helpText", "initialText"]); if let Some(options) = question.get_mut("options").and_then(Value::as_array_mut) { for option in options { if let Some(option) = option.as_object_mut() { @@ -617,6 +643,15 @@ pub(crate) fn validate_question_set(value: &Value) -> Result<(), LocalRunnerErro "ACPX input question ids must be unique", )); } + if question + .get("initialText") + .and_then(Value::as_str) + .is_some_and(|text| text.chars().count() > 100_000) + { + return Err(LocalRunnerError::invalid( + "ACPX initial text exceeds its Unicode code-point bound", + )); + } let mut option_ids = BTreeSet::new(); for option in question .get("options") 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 48f8ca0e793..098f0d2cad5 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 @@ -14,7 +14,7 @@ use sha2::{Digest, Sha256}; use crate::acpx_provider_session::{ AcpxPermissionMode, AcpxProviderRuntimePolicy, AcpxProviderSession, AcpxProviderSessionConfig, - AcpxProviderSessionIdentity, AcpxTurnControlCapabilities, + AcpxProviderSessionIdentity, AcpxTurnControlCapabilities, CursorMode, }; use crate::acpx_sidecar_transport::AcpxSidecarTransportConfig; #[cfg(test)] @@ -135,6 +135,8 @@ struct AcpxProviderDescriptor { #[serde(default)] instructions: String, permission_mode: AcpxPermissionMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + cursor_mode: Option, permission_mode_pinned: bool, #[serde(default)] provider_policy: Option, @@ -142,6 +144,41 @@ struct AcpxProviderDescriptor { runtime_context: Value, } +// Only controller-authenticated run grants move between provider lifetimes. +// Keep prompt, bundle/skill identities, assignment policy, and unknown fields +// in this comparison; a new filesystem copy is not a new provider profile. +fn runtime_context_session_identity(context: &Value) -> Option { + let mut identity = context.clone(); + for pointer in [ + "/instructions/bundle/rootPath", + "/instructions/workingCopy/rootPath", + ] { + if let Some(value) = identity.pointer_mut(pointer) { + if !value.as_str().is_some_and(|path| !path.is_empty()) { + return None; + } + *value = Value::Null; + } + } + if let Some(skills) = identity.get_mut("skills").and_then(Value::as_array_mut) { + for skill in skills { + if let Some(value) = skill.pointer_mut("/bundle/rootPath") { + if !value.as_str().is_some_and(|path| !path.is_empty()) { + return None; + } + *value = Value::Null; + } + } + } + if let Some(value) = identity.pointer_mut("/mcp/bindingId") { + if !value.is_null() && !value.as_str().is_some_and(|binding| !binding.is_empty()) { + return None; + } + *value = Value::Null; + } + Some(identity) +} + impl AcpxProviderDescriptor { fn validate_session( &self, @@ -178,7 +215,7 @@ impl AcpxProviderDescriptor { "2026.09.26-dd393fe", None, None, - "sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899", + "sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e", ), "copilot" => ( self.model.as_str(), @@ -211,6 +248,7 @@ impl AcpxProviderDescriptor { || self.model.trim().is_empty() || self.model.len() > 240 || self.model.contains('\0') + || ((self.agent == "cursor") != self.cursor_mode.is_some()) || (matches!(self.agent.as_str(), "pi" | "cursor" | "copilot") && self.provider_policy.is_none()) || self.agent_server_package != expected.1 @@ -306,6 +344,7 @@ impl AcpxProviderDescriptor { normalized_session_id: self.normalized_session_id.clone(), working_directory: PathBuf::from(&self.cwd), permission_mode: self.permission_mode, + cursor_mode: self.cursor_mode, permission_mode_pinned: self.permission_mode_pinned, provider_policy: self.provider_policy.clone(), system_instructions: self.instructions.clone(), @@ -389,7 +428,7 @@ impl AcpxProviderDescriptor { } fn public_descriptor(&self, identity: Option<&AcpxProviderSessionIdentity>) -> Value { - json!({ + let mut descriptor = json!({ "provider": "acpx", "driver": "acpx_runtime", "providerVersion": self.provider_version, @@ -405,7 +444,11 @@ impl AcpxProviderDescriptor { "providerSessionId": identity.map(|value| value.agent_session_id.as_str()), "acpxRecordId": identity.map(|value| value.acpx_record_id.as_str()), "permissionMode": self.permission_mode, - }) + }); + if let Some(mode) = self.cursor_mode { + descriptor["cursorMode"] = json!(mode); + } + descriptor } } @@ -535,9 +578,11 @@ impl AcpxDurableState { identity .validate() .map_err(|error| DurableRunnerError::invalid(error.to_string()))?; - if identity.profile_digest != self.descriptor.command_digest { + if identity.profile_digest != self.descriptor.command_digest + || identity.cursor_mode != self.descriptor.cursor_mode + { return Err(DurableRunnerError::invalid( - "ACPX durable identity no longer matches its qualified profile digest", + "ACPX durable identity no longer matches its qualified profile or Cursor mode", )); } } @@ -1056,6 +1101,25 @@ impl AcpxCommandExecutor { object.remove("aggregateDigest"); } } + if descriptor.agent == "cursor" { + // New runs rotate authenticated instruction text and registered + // file-copy grants. Preserve mainline MCP refresh while comparing + // every remaining context identity, including unknown policy fields. + let compatible = runtime_context_session_identity(&durable_descriptor.runtime_context) + .zip(runtime_context_session_identity( + &previous_descriptor.runtime_context, + )) + .is_some_and(|(current, prior)| current == prior); + let grants_changed = descriptor.instructions != state.descriptor.instructions + || descriptor.runtime_context != state.descriptor.runtime_context; + if !compatible || (grants_changed && descriptor.run_id == state.descriptor.run_id) { + return Err(DurableRunnerError::invalid( + "Cursor run.attach changed runtime context outside a new authenticated run", + )); + } + durable_descriptor.instructions = previous_descriptor.instructions.clone(); + durable_descriptor.runtime_context = previous_descriptor.runtime_context.clone(); + } let only_recovery_notice_pending = state .pending_events .iter() @@ -1848,7 +1912,16 @@ impl AcpxCommandExecutor { } if let Some(event_type) = terminal { state.active_turn_id = None; - state.lifecycle = "session_open".to_owned(); + state.lifecycle = if self + .session + .as_ref() + .is_some_and(|session| session.runtime_retired()) + { + "closed" + } else { + "session_open" + } + .to_owned(); provider_turn_settled = true; // An ACP goal has session lifetime, not prompt lifetime. // Out-of-prompt goal updates remain observable after quiescence. @@ -2260,6 +2333,15 @@ mod tests { json!("0.3.280"), "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", ) + } else if agent == "cursor" { + ( + "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "cursor-agent", + "2026.09.26-dd393fe", + Value::Null, + Value::Null, + "sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e", + ) } else { ( "gpt-5.6-sol", @@ -2270,7 +2352,7 @@ mod tests { "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", ) }; - json!({ + let mut value = json!({ "kind": "acpx", "provider": "acpx", "driver": "acpx_runtime", @@ -2293,7 +2375,12 @@ mod tests { "permissionMode": "approve-reads", "permissionModePinned": true, "runtimeContext": null, - }) + }); + if agent == "cursor" { + value["cursorMode"] = json!("agent"); + value["providerPolicy"] = json!({"readOnly": true}); + } + value } fn pending_input_request(id: &str) -> Value { @@ -2462,6 +2549,7 @@ mod tests { requested_model: descriptor.model.clone(), effective_model: descriptor.model.clone(), permission_mode: Some(descriptor.permission_mode), + cursor_mode: descriptor.cursor_mode, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; let operations = Vec::new(); @@ -2711,6 +2799,7 @@ mod tests { requested_model: "gpt-5.6-sol".to_owned(), effective_model: "gpt-5.6-sol".to_owned(), permission_mode: Some(AcpxPermissionMode::ApproveReads), + cursor_mode: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; @@ -2734,7 +2823,7 @@ mod tests { "cursor", "cursor-agent", "2026.09.26-dd393fe", - "sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899", + "sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e", None, None, "explicit-model", @@ -2769,8 +2858,82 @@ mod tests { let missing: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap(); assert!(missing.validate(&context()).is_err()); value["providerPolicy"] = json!({"readOnly":true}); + if agent == "cursor" { + value["cursorMode"] = json!("agent"); + } let valid: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap(); valid.validate(&context()).unwrap(); + if matches!(agent, "cursor" | "copilot") { + let mut previous_v9 = value.clone(); + previous_v9["commandDigest"] = json!(match agent { + "cursor" => + "sha256:a76ad26878a3b3328154901563cbda857e53583e4e01787f35a797992ef76162", + "copilot" => + "sha256:98936d763497bd6f0e5605f52831a344f456357de58c457e440e69a1a468a2f4", + _ => unreachable!(), + }); + let previous_v9: AcpxProviderDescriptor = + serde_json::from_value(previous_v9).unwrap(); + assert!(previous_v9.validate(&context()).is_err()); + } + if agent == "cursor" { + let mut previous_v8 = value.clone(); + previous_v8["commandDigest"] = json!( + "sha256:b9e94cbcdce2783665612c85caf019c4defe37d3d245a694de590ebdf5b1f1a3" + ); + let previous_v8: AcpxProviderDescriptor = + serde_json::from_value(previous_v8).unwrap(); + assert!(previous_v8.validate(&context()).is_err()); + } + let mut previous_identity = value.clone(); + previous_identity["commandDigest"] = json!(match agent { + "cursor" => + "sha256:f4c7af914738149cf868d071e53ac4917658fb055224715a2c89d3f59b335503", + "copilot" => + "sha256:b11721382293b39c1d6dd363eae547b5eae0ccca2cd5dcafc9127cc060ee9526", + "pi" => "sha256:edf058835ee84de3869c4a8e8bdb71a934ffdb9f34daa37ae6faeb92371d1cdf", + _ => unreachable!(), + }); + let previous_identity: AcpxProviderDescriptor = + serde_json::from_value(previous_identity).unwrap(); + assert!(previous_identity.validate(&context()).is_err()); + let mut previous_contract = value.clone(); + previous_contract["commandDigest"] = json!(match agent { + "cursor" => + "sha256:377dcea64a727ce799cc112458d4b40ba4bc6574cd6c6f7233b6efd5917a6c4b", + "copilot" => + "sha256:ece77e40876631a69a828b91813722001d71b6fc81be47ecd4b3dced84ff9473", + "pi" => "sha256:843d30e419914529755c9827d9151a306da1b50b643be7eab1abe797641a37ce", + _ => unreachable!(), + }); + let previous_contract: AcpxProviderDescriptor = + serde_json::from_value(previous_contract).unwrap(); + assert!(previous_contract.validate(&context()).is_err()); + if agent == "cursor" { + assert_eq!(valid.public_descriptor(None)["cursorMode"], json!("agent")); + let mut previous_v4 = value.clone(); + previous_v4["commandDigest"] = json!( + "sha256:b1440d559ebc4eef5c7a582f1c81fc153270cfbafa1731a8ee76d83713bdf61b" + ); + let previous_v4: AcpxProviderDescriptor = + serde_json::from_value(previous_v4).unwrap(); + assert!(previous_v4.validate(&context()).is_err()); + let mut missing = value.clone(); + missing.as_object_mut().unwrap().remove("cursorMode"); + let missing: AcpxProviderDescriptor = serde_json::from_value(missing).unwrap(); + assert!(missing.validate(&context()).is_err()); + let mut unknown = value.clone(); + unknown["cursorMode"] = json!("autopilot"); + assert!(serde_json::from_value::(unknown).is_err()); + } else { + assert!(valid.public_descriptor(None).get("cursorMode").is_none()); + let mut wrong_agent = value.clone(); + wrong_agent["cursorMode"] = json!("plan"); + let wrong_agent: AcpxProviderDescriptor = + serde_json::from_value(wrong_agent).unwrap(); + assert!(wrong_agent.validate(&context()).is_err()); + } + for field in ["model", "agentServerVersion", "commandDigest"] { let mut wrong = value.clone(); wrong[field] = json!(""); @@ -2898,6 +3061,7 @@ mod tests { requested_model: original_descriptor.model.clone(), effective_model: original_descriptor.model.clone(), permission_mode: Some(original_descriptor.permission_mode), + cursor_mode: original_descriptor.cursor_mode, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; let operations = Vec::new(); @@ -3056,6 +3220,273 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } + #[cfg(unix)] + #[test] + fn cursor_attachment_rotates_only_authenticated_run_grants() { + let directory = temporary_directory("cursor-cross-run-attach"); + let runtime = directory.join("runtime"); + let workspace = directory.join("workspace"); + fs::create_dir_all(&runtime).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(&workspace, fs::Permissions::from_mode(0o700)).unwrap(); + let marker = directory.join("provider-started"); + let command = directory.join("sidecar"); + write_artifact( + &command, + format!("#!/bin/sh\ntouch '{}'\n", marker.display()).as_bytes(), + true, + ); + let launch_profile = AcpxLaunchProfile { + authority_digest: format!("sha256:{}", "d".repeat(64)), + command: command.clone(), + args: Vec::new(), + artifacts: vec![artifact(&command)], + }; + let mut descriptor_value = descriptor("cursor"); + descriptor_value["sidecarCommand"] = json!(command); + descriptor_value["runtimeContext"] = json!({ "instructions": { "digest": "stable" }, "mcp": { "digest": "before" }, "aggregateDigest": "before" }); + descriptor_value["sidecarArgs"] = json!([]); + descriptor_value["runtimeDirectory"] = json!(runtime); + descriptor_value["cwd"] = json!(workspace); + let prior_root = directory.join("old-registered-copy"); + let current_root = directory.join("new-registered-copy"); + fs::create_dir_all(&prior_root).unwrap(); + fs::create_dir_all(¤t_root).unwrap(); + descriptor_value["instructions"] = json!(format!( + "Current AGENT_HOME: {}. Custom entry.", + prior_root.display() + )); + descriptor_value["runtimeContext"] = json!({ + "aggregateDigest": "a".repeat(64), + "prompt": {"revision": "pinned", "digest": "b".repeat(64)}, + "instructions": { + "entryPath": "AGENTS.md", + "bundle": {"digest": "c".repeat(64), "rootPath": "/old-bundle"}, + "workingCopy": {"kind": "agent_files", "entryPath": "AGENTS.md", "rootPath": prior_root}, + }, + "skills": [{"key": "skill-1", "bundle": {"digest": "d".repeat(64), "rootPath": "/old-skill"}}], + "mcp": {"assignmentSetId": "assignment-1", "digest": "e".repeat(64), "bindingId": "old-run-binding"}, + "futurePolicy": {"companyId": "company-1"}, + }); + let original_descriptor: AcpxProviderDescriptor = + serde_json::from_value(descriptor_value.clone()).unwrap(); + let identity = AcpxProviderSessionIdentity { + kind: "acpx".to_owned(), + normalized_session_id: "session-1".to_owned(), + acpx_record_id: "record-1".to_owned(), + backend_session_id: "backend-1".to_owned(), + agent_session_id: "agent-1".to_owned(), + profile_digest: original_descriptor.command_digest.clone(), + workspace_digest: format!("sha256:{}", "a".repeat(64)), + requested_model: original_descriptor.model.clone(), + effective_model: original_descriptor.model.clone(), + permission_mode: Some(original_descriptor.permission_mode), + cursor_mode: original_descriptor.cursor_mode, + provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], + }; + let operations = Vec::new(); + let tool_set = AuthorizedToolSet { + schema: TOOL_SET_SCHEMA.to_owned(), + schema_version: 1, + catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(), + operations, + }; + let launch_profile_digest = launch_profile.canonical_digest().unwrap(); + let mut state = AcpxDurableState::new(original_descriptor, tool_set, launch_profile_digest); + state.lifecycle = "suspended".to_owned(); + state.identity = Some(identity); + let original_config = test_config(&directory, Some(launch_profile.clone())); + let mut original = AcpxCommandExecutor::with_runner_config(&directory, &original_config); + original.state = Some(state); + let settled = original.state.clone().unwrap(); + let ready = original.snapshot().unwrap().result; + assert_eq!(ready["warmAttachReady"], true); + assert_eq!(ready["warmAttachBlockers"], json!([])); + let blocked_states: [(&str, fn(&mut AcpxDurableState)); 4] = [ + ("durable_closed", |state| { + state.lifecycle = "closed".to_owned() + }), + ("provider_exit_unconfirmed", |state| { + state.lifecycle = "prepared".to_owned(); + state.provider_exit_unconfirmed = true; + }), + ("provider_identity_unavailable", |state| { + state.lifecycle = "prepared".to_owned(); + state.identity = None; + }), + ("durable_active_turn", |state| { + state.lifecycle = "turn_active".to_owned(); + state.active_turn_id = Some("turn-1".to_owned()); + }), + ]; + for (blocker, mutate) in blocked_states { + original.state = Some(settled.clone()); + mutate(original.state.as_mut().unwrap()); + let snapshot = original.snapshot().unwrap().result; + assert_eq!(snapshot["warmAttachReady"], false, "{blocker}"); + assert_eq!(snapshot["warmAttachBlockers"], json!([blocker])); + } + // A readiness probe must retain the old authority's audit events until + // the durable runner commits and acknowledges them, including recovery + // notices that run.attach itself is allowed to consume. + for event_type in ["session.resumed", "harness.diagnostic"] { + original.state = Some(settled.clone()); + original + .state + .as_mut() + .unwrap() + .push(NormalizedProviderEvent { + event_type: event_type.to_owned(), + priority: EventPriority::P0, + payload: json!({}), + }) + .unwrap(); + let snapshot = original.snapshot().unwrap().result; + assert_eq!(snapshot["warmAttachReady"], false); + assert_eq!( + snapshot["warmAttachBlockers"], + json!(["durable_pending_events"]) + ); + assert_eq!(original.retained_events().unwrap().len(), 1); + original.acknowledge_events(1).unwrap(); + assert_eq!(original.snapshot().unwrap().result["warmAttachReady"], true); + } + original.state = Some(settled); + assert!(!marker.exists(), "readiness must not start a provider"); + original.save_state().unwrap(); + + let mut wrong_session_config = original_config.clone(); + wrong_session_config.run_id = "run-2".to_owned(); + wrong_session_config.normalized_session_id = "session-2".to_owned(); + let mut wrong_session = + AcpxCommandExecutor::with_runner_config(&directory, &wrong_session_config); + assert!(wrong_session.restore().is_err()); + + let mut attached_config = original_config.clone(); + attached_config.run_id = "run-2".to_owned(); + let mut attached = AcpxCommandExecutor::with_runner_config(&directory, &attached_config); + attached.restore().unwrap(); + assert!(!marker.exists()); + let non_attach_error = attached + .execute(&Command { + schema: "paperclip.prp.command.v1".to_owned(), + command_id: "command-before-attach".to_owned(), + controller_seq: 1, + command_type: "session.snapshot".to_owned(), + issued_at: "2026-09-01T00:00:00.000Z".to_owned(), + deadline_at: None, + precondition: None, + payload: json!({}), + }) + .unwrap_err(); + assert!(non_attach_error + .to_string() + .contains("requires run.attach before commands from a new run")); + + descriptor_value["runId"] = json!("run-2"); + fs::remove_dir_all(&prior_root).unwrap(); + descriptor_value["instructions"] = json!(format!( + "Current AGENT_HOME: {}. Fresh custom entry.", + current_root.display() + )); + descriptor_value["runtimeContext"]["instructions"]["workingCopy"]["rootPath"] = + json!(current_root); + descriptor_value["runtimeContext"]["instructions"]["bundle"]["rootPath"] = + json!("/new-bundle"); + descriptor_value["runtimeContext"]["skills"][0]["bundle"]["rootPath"] = json!("/new-skill"); + descriptor_value["runtimeContext"]["mcp"]["bindingId"] = json!("new-run-binding"); + attached + .attach_run(&json!({"provider": descriptor_value})) + .unwrap(); + assert_eq!(attached.state.as_ref().unwrap().descriptor.run_id, "run-2"); + assert!(!marker.exists()); + let refreshed = &attached.state.as_ref().unwrap().descriptor; + assert_eq!( + refreshed.runtime_context, + descriptor_value["runtimeContext"] + ); + assert_eq!(refreshed.instructions, descriptor_value["instructions"]); + let session_config = refreshed + .session_config( + attached.state.as_ref().unwrap().tool_set.clone(), + attached.state.as_ref().unwrap().identity.clone(), + Some(&launch_profile), + ) + .unwrap(); + assert_eq!( + session_config.runtime_context, + descriptor_value["runtimeContext"] + ); + assert_eq!( + session_config.system_instructions, + descriptor_value["instructions"] + ); + // Both persistence and the sidecar launch config receive the new grant. + let persisted: AcpxDurableState = + serde_json::from_slice(&fs::read(attached.state_path()).unwrap()).unwrap(); + assert_eq!( + persisted.descriptor.runtime_context, + descriptor_value["runtimeContext"] + ); + let mut same_run_mutation = descriptor_value.clone(); + same_run_mutation["instructions"] = json!("A different grant in the same run"); + assert!(attached + .attach_run(&json!({"provider": same_run_mutation})) + .unwrap_err() + .to_string() + .contains("new authenticated run")); + + // In-place warm handoff executes under the old authority. Only the + // authenticated next-authority boundary may admit the new descriptor; + // event correlation stays on run-1 until durable activation completes. + assert!(original + .attach_run(&json!({"provider": descriptor_value})) + .is_err()); + let warm_payload = json!({ + "provider": descriptor_value, + "paperclipNextAuthority": { + "identity": { + "runnerInstanceId": original_config.runner_instance_id, + "environmentLeaseId": original_config.environment_lease_id, + "runId": "run-2", + "normalizedSessionId": original_config.normalized_session_id, + "turnId": "turn-2", + "itemId": "item-2", + }, + "connection": {"mode": "connect", "connectUrl": original_config.connect_url}, + }, + }); + let mut wrong_run = warm_payload.clone(); + wrong_run["paperclipNextAuthority"]["identity"]["runId"] = json!("run-3"); + assert!(original.attach_run(&wrong_run).is_err()); + let mut wrong_session = warm_payload.clone(); + wrong_session["paperclipNextAuthority"]["identity"]["normalizedSessionId"] = + json!("other-session"); + assert!(original.attach_run(&wrong_session).is_err()); + let mut changed_profile = warm_payload.clone(); + changed_profile["provider"]["cwd"] = json!("/different-workspace"); + assert!(original.attach_run(&changed_profile).is_err()); + let mut refreshed = warm_payload.clone(); + refreshed["provider"]["runtimeContext"]["mcp"] = json!({ "digest": "after" }); + refreshed["provider"]["runtimeContext"]["aggregateDigest"] = json!("after"); + let mut changed_context = refreshed.clone(); + changed_context["provider"]["runtimeContext"]["instructions"] = + json!({ "digest": "changed" }); + assert!(original.attach_run(&changed_context).is_err()); + original.attach_run(&refreshed).unwrap(); + assert_eq!( + original.state.as_ref().unwrap().descriptor.runtime_context["mcp"]["digest"], + "after" + ); + assert_eq!(original.state.as_ref().unwrap().descriptor.run_id, "run-2"); + assert_eq!(original.context.run_id, "run-1"); + original.rotate_authority(&attached_config); + assert_eq!(original.context.run_id, "run-2"); + assert!(!marker.exists()); + fs::remove_dir_all(directory).unwrap(); + } + #[cfg(unix)] #[test] fn active_turn_recovery_closes_without_starting_the_provider() { @@ -3098,6 +3529,7 @@ mod tests { requested_model: descriptor.model.clone(), effective_model: descriptor.model.clone(), permission_mode: Some(descriptor.permission_mode), + cursor_mode: descriptor.cursor_mode, provider_lifetime_fence_candidates, }; let operations = Vec::new(); @@ -3269,6 +3701,7 @@ mod tests { requested_model: provider_descriptor.model.clone(), effective_model: provider_descriptor.model.clone(), permission_mode: Some(provider_descriptor.permission_mode), + cursor_mode: provider_descriptor.cursor_mode, provider_lifetime_fence_candidates, }); state.provider_exit_unconfirmed = true; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs index 0e4c332ce34..ac9cdd83f77 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs @@ -10,7 +10,7 @@ use std::os::unix::fs::DirBuilderExt; use serde::{Deserialize, Serialize}; use crate::acpx_provider_session::{ - AcpxPermissionMode, AcpxProviderSessionConfig, AcpxProviderSessionIdentity, + AcpxPermissionMode, AcpxProviderSessionConfig, AcpxProviderSessionIdentity, CursorMode, }; use crate::durable::{ create_private_temporary_file, open_private_regular_file, verify_private_directory, @@ -48,6 +48,8 @@ struct PersistedAcpxProviderSessionIdentity { requested_model: String, effective_model: String, permission_mode: AcpxPermissionMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + cursor_mode: Option, provider_lifetime_fence_candidates: [u16; 3], } @@ -70,6 +72,7 @@ impl PersistedAcpxProviderSessionIdentity { requested_model: identity.requested_model, effective_model: identity.effective_model, permission_mode, + cursor_mode: identity.cursor_mode, provider_lifetime_fence_candidates: identity.provider_lifetime_fence_candidates, }) } @@ -86,6 +89,7 @@ impl PersistedAcpxProviderSessionIdentity { requested_model: self.requested_model.clone(), effective_model: self.effective_model.clone(), permission_mode: Some(self.permission_mode), + cursor_mode: self.cursor_mode, provider_lifetime_fence_candidates: self.provider_lifetime_fence_candidates, } } @@ -106,6 +110,7 @@ impl AcpxSuspensionCheckpoint { || identity.requested_model != config.model || identity.effective_model != config.model || identity.permission_mode != Some(config.permission_mode) + || identity.cursor_mode != config.cursor_mode || config .expected_identity .as_ref() diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs index a50de43ef68..60f2684ddb4 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs @@ -10,7 +10,8 @@ use crate::acpx_provider_state::{ }; use crate::acpx_sidecar_transport::{AcpxSidecarTransport, AcpxSidecarTransportConfig}; use crate::generated_acpx_sidecar_contract::{ - GeneratedAcpxSidecarCommand, GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION, + GeneratedAcpxSidecarCommand, GeneratedAcpxSidecarEventType, + GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION, }; use crate::local_runner::LocalRunnerError; use crate::provider_bridge::{ @@ -34,6 +35,14 @@ pub enum AcpxPermissionMode { DenyAll, } +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CursorMode { + Agent, + Plan, + Ask, +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AcpxProviderSessionIdentity { @@ -48,6 +57,8 @@ pub struct AcpxProviderSessionIdentity { pub effective_model: String, #[serde(default)] pub permission_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor_mode: Option, pub provider_lifetime_fence_candidates: [u16; 3], } @@ -68,6 +79,7 @@ pub struct AcpxProviderSessionConfig { pub normalized_session_id: String, pub working_directory: PathBuf, pub permission_mode: AcpxPermissionMode, + pub cursor_mode: Option, pub permission_mode_pinned: bool, pub provider_policy: Option, pub system_instructions: String, @@ -98,6 +110,11 @@ impl AcpxProviderSessionConfig { ))); } validate_text(&self.model, MAX_MODEL_CHARS, "ACPX model")?; + if (self.agent == "cursor") != self.cursor_mode.is_some() { + return Err(LocalRunnerError::invalid( + "ACPX Cursor mode must be explicit for Cursor and absent for other agents", + )); + } if matches!(self.agent.as_str(), "pi" | "cursor" | "copilot") && self.provider_policy.is_none() { @@ -169,6 +186,7 @@ impl AcpxProviderSessionConfig { || expected_identity.requested_model != self.model || expected_identity.effective_model != self.model || expected_identity.permission_mode != Some(self.permission_mode) + || expected_identity.cursor_mode != self.cursor_mode { return Err(LocalRunnerError::invalid( "ACPX expected identity conflicts with the requested session", @@ -261,6 +279,7 @@ pub struct AcpxProviderSession { working_directory: PathBuf, closed: bool, transport_terminated: bool, + runtime_retired: bool, } impl AcpxProviderSession { @@ -295,9 +314,14 @@ impl AcpxProviderSession { working_directory: config.working_directory.clone(), closed: false, transport_terminated: false, + runtime_retired: false, }) } + pub fn runtime_retired(&self) -> bool { + self.runtime_retired + } + pub fn process_id(&self) -> u32 { self.transport.process_id() } @@ -343,6 +367,11 @@ impl AcpxProviderSession { working_directory: &Path, ) -> Result { self.ensure_open()?; + if self.runtime_retired { + return Err(LocalRunnerError::invalid( + "ACPX provider runtime was retired by cancellation", + )); + } validate_stable_id(turn_id, DURABLE_STABLE_ID_CHARS, "ACPX turn id")?; validate_turn_message(message)?; if working_directory != self.working_directory { @@ -514,6 +543,9 @@ impl AcpxProviderSession { "ACPX sidecar did not confirm turn cancellation", ))); } + // Polling still owns the terminal frame queued before this response. + // Only future prompt admission is revoked by the confirmed close. + self.runtime_retired = response.get("sessionClosed").and_then(Value::as_bool) == Some(true); Ok(response) } @@ -526,9 +558,27 @@ impl AcpxProviderSession { Ok(event) => event, Err(error) => return Err(self.fail_closed(error)), }; - let Some(event) = event else { + let Some(mut event) = event else { return Ok(None); }; + if event.event_type == GeneratedAcpxSidecarEventType::RuntimePermissionRequested { + // Authority comes from this admitted connection's profile. A sidecar + // claim cannot relabel another provider; old frames may omit origin. + let origin = json!({"adapter":"acpx-runtime-sidecar", "provider":self.config.agent, + "method":"session/request_permission"}); + if event + .payload + .get("origin") + .is_some_and(|claimed| claimed != &origin) + { + return Err(self.fail_closed(LocalRunnerError::invalid( + "ACPX permission origin conflicts with the admitted provider profile", + ))); + } + if let Some(payload) = event.payload.as_object_mut() { + payload.insert("origin".to_owned(), origin); + } + } let mut next_state = self.state.clone(); let events = match next_state.accept_event(&event) { Ok(events) => events, @@ -1201,6 +1251,27 @@ impl Drop for AcpxProviderSession { } } +fn session_open_params(config: &AcpxProviderSessionConfig, sidecar_tools: &[Value]) -> Value { + let mut params = json!({ + "runtimeDirectory": config.runtime_directory, + "normalizedSessionId": config.normalized_session_id, + "workingDirectory": config.working_directory, + "agent": config.agent, + "model": config.model, + "permissionMode": config.permission_mode, + "permissionModePinned": config.permission_mode_pinned, + "providerPolicy": config.provider_policy, + "systemInstructions": config.system_instructions, + "runtimeContext": config.runtime_context, + "tools": &sidecar_tools, + "expectedIdentity": config.expected_identity, + }); + if let Some(mode) = config.cursor_mode { + params["cursorMode"] = json!(mode); + } + params +} + fn bootstrap( transport: &mut AcpxSidecarTransport, config: &AcpxProviderSessionConfig, @@ -1221,20 +1292,7 @@ fn bootstrap( let opened = transport.request( GeneratedAcpxSidecarCommand::SessionOpen, - json!({ - "runtimeDirectory": config.runtime_directory, - "normalizedSessionId": config.normalized_session_id, - "workingDirectory": config.working_directory, - "agent": config.agent, - "model": config.model, - "permissionMode": config.permission_mode, - "permissionModePinned": config.permission_mode_pinned, - "providerPolicy": config.provider_policy, - "systemInstructions": config.system_instructions, - "runtimeContext": config.runtime_context, - "tools": &sidecar_tools, - "expectedIdentity": config.expected_identity, - }), + session_open_params(config, &sidecar_tools), )?; let identity = verify_open_response(&opened, transport.process_id(), config)?; let turn_controls = verified_turn_controls(opened.get("turnControls"), &config.agent)?; @@ -1320,6 +1378,7 @@ fn verify_open_response( || identity.requested_model != config.model || identity.effective_model != config.model || identity.permission_mode != Some(config.permission_mode) + || identity.cursor_mode != config.cursor_mode || config .expected_identity .as_ref() @@ -1542,3 +1601,142 @@ mod permission_mode_tests { ); } } + +#[cfg(test)] +mod cursor_mode_tests { + use super::*; + + fn config() -> AcpxProviderSessionConfig { + let operations = Vec::new(); + AcpxProviderSessionConfig { + transport: AcpxSidecarTransportConfig { + command: std::env::current_exe().unwrap(), + args: Vec::new(), + verified_launch: None, + request_timeout: Duration::from_secs(1), + shutdown_grace: Duration::from_millis(100), + }, + agent: "cursor".to_owned(), + model: "explicit-model".to_owned(), + run_id: "run-1".to_owned(), + catalog_revision: 1, + runtime_directory: std::env::temp_dir(), + normalized_session_id: "session-1".to_owned(), + working_directory: std::env::temp_dir(), + permission_mode: AcpxPermissionMode::ApproveReads, + cursor_mode: Some(CursorMode::Plan), + permission_mode_pinned: true, + provider_policy: Some(AcpxProviderRuntimePolicy { read_only: false }), + system_instructions: String::new(), + runtime_context: Value::Null, + tool_set: AuthorizedToolSet { + schema: TOOL_SET_SCHEMA.to_owned(), + schema_version: 1, + catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(), + operations, + }, + expected_identity: None, + } + } + fn identity() -> AcpxProviderSessionIdentity { + AcpxProviderSessionIdentity { + kind: "acpx".to_owned(), + normalized_session_id: "session-1".to_owned(), + acpx_record_id: "record-1".to_owned(), + backend_session_id: "backend-1".to_owned(), + agent_session_id: "agent-1".to_owned(), + profile_digest: format!("sha256:{}", "1".repeat(64)), + workspace_digest: format!("sha256:{}", "2".repeat(64)), + requested_model: "explicit-model".to_owned(), + effective_model: "explicit-model".to_owned(), + permission_mode: Some(AcpxPermissionMode::ApproveReads), + cursor_mode: Some(CursorMode::Plan), + provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], + } + } + #[test] + fn cursor_mode_is_closed_and_not_defaulted_in_rust() { + for (name, mode) in [ + ("agent", CursorMode::Agent), + ("plan", CursorMode::Plan), + ("ask", CursorMode::Ask), + ] { + assert_eq!( + serde_json::from_value::(json!(name)).unwrap(), + mode + ); + assert_eq!(serde_json::to_value(mode).unwrap(), json!(name)); + } + for value in [json!("Agent"), json!("autopilot"), json!(null), json!(1)] { + assert!(serde_json::from_value::(value).is_err()); + } + let mut config = config(); + config.validate().unwrap(); + config.cursor_mode = None; + assert!(config.validate().is_err()); + config.agent = "copilot".to_owned(); + config.cursor_mode = Some(CursorMode::Agent); + assert!(config.validate().is_err()); + config.cursor_mode = None; + config.validate().unwrap(); + } + #[test] + fn open_wire_and_identity_bind_exact_cursor_mode() { + let mut config = config(); + for mode in [CursorMode::Agent, CursorMode::Plan, CursorMode::Ask] { + config.cursor_mode = Some(mode); + assert_eq!(session_open_params(&config, &[])["cursorMode"], json!(mode)); + let mut identity = identity(); + identity.cursor_mode = Some(mode); + let response = json!({"sidecarPid": 100, "status": {}, "identity": identity}); + assert_eq!( + verify_open_response(&response, 100, &config) + .unwrap() + .cursor_mode, + Some(mode) + ); + for wrong in [ + None, + Some(CursorMode::Agent), + Some(CursorMode::Plan), + Some(CursorMode::Ask), + ] { + if wrong == Some(mode) { + continue; + } + let mut changed = response.clone(); + changed["identity"]["cursorMode"] = json!(wrong); + assert!(verify_open_response(&changed, 100, &config).is_err()); + } + } + config.agent = "copilot".to_owned(); + config.cursor_mode = None; + assert!(session_open_params(&config, &[]) + .get("cursorMode") + .is_none()); + let mut other = identity(); + other.cursor_mode = None; + assert!(serde_json::to_value(&other) + .unwrap() + .get("cursorMode") + .is_none()); + let response = json!({"sidecarPid": 100, "status": {}, "identity": other}); + verify_open_response(&response, 100, &config).unwrap(); + } + #[test] + fn warm_reopen_and_suspension_reject_mode_changes() { + let mut config = config(); + let identity = identity(); + config.expected_identity = Some(identity.clone()); + config.validate().unwrap(); + config.cursor_mode = Some(CursorMode::Ask); + assert!(config.validate().is_err()); + let mut changed = identity.clone(); + changed.cursor_mode = Some(CursorMode::Agent); + assert!(verify_suspend_response( + &json!({"suspended": true, "identity": changed}), + &identity + ) + .is_err()); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_state.rs index ceef3283952..b5d860a17e8 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_state.rs @@ -57,11 +57,13 @@ pub enum AcpxProviderStateEvent { details: Value, }, InputRequest { + tool_call_id: Option, request_id: String, question_set: Value, origin: Option, }, RuntimeRequestEnded { + tool_call_id: Option, request_id: String, question_set: Option, origin: Option, @@ -87,6 +89,7 @@ pub enum AcpxProviderStateEvent { #[derive(Clone, Debug, PartialEq)] struct PendingInput { + tool_call_id: Option, runtime_request_id: String, value_bytes: usize, question_set: Value, @@ -106,6 +109,8 @@ pub struct AcpxProviderState { plan_revision: u64, assistant_text: String, assistant_message_id: Option, + pi_assistant_message: Option, + pi_assistant_message_ids: BTreeSet, pending_tools: BTreeMap, pending_tool_input_bytes: usize, pending_permissions: BTreeMap, @@ -123,6 +128,8 @@ impl AcpxProviderState { plan_revision: 0, assistant_text: String::new(), assistant_message_id: None, + pi_assistant_message: None, + pi_assistant_message_ids: BTreeSet::new(), pending_tools: BTreeMap::new(), pending_tool_input_bytes: 0, pending_permissions: BTreeMap::new(), @@ -215,6 +222,8 @@ impl AcpxProviderState { self.plan_revision = 0; self.assistant_text.clear(); self.assistant_message_id = None; + self.pi_assistant_message = None; + self.pi_assistant_message_ids.clear(); self.semantic_result = None; self.attempted_turn_controls.clear(); Ok(()) @@ -276,11 +285,13 @@ impl AcpxProviderState { }]) } AcpxEventPayload::InputRequested { + tool_call_id, request_id, question_set, origin, } => { let value_bytes = value_bytes(&question_set)? + + tool_call_id.as_ref().map(|value| value.len()).unwrap_or(0) + origin.as_ref().map(value_bytes).transpose()?.unwrap_or(0); self.admit_runtime_request(&request_id, value_bytes)?; let runtime_request_id = project_acpx_runtime_request_id(&request_id) @@ -300,6 +311,7 @@ impl AcpxProviderState { .insert( request_id.clone(), PendingInput { + tool_call_id: tool_call_id.clone(), runtime_request_id, value_bytes, question_set: question_set.clone(), @@ -314,6 +326,7 @@ impl AcpxProviderState { } self.pending_runtime_request_bytes += value_bytes; Ok(vec![AcpxProviderStateEvent::InputRequest { + tool_call_id, request_id, question_set, origin, @@ -358,6 +371,11 @@ impl AcpxProviderState { }]) } AcpxEventPayload::TurnTerminal { status, error } => { + if status == AcpxTurnStatus::Completed && self.pi_assistant_message.is_some() { + return Err(LocalRunnerError::invalid( + "Pi assistant message ended without its native boundary", + )); + } let turn_id = event .turn_id .as_deref() @@ -376,6 +394,8 @@ impl AcpxProviderState { self.assistant_text.clear(); } self.assistant_message_id = None; + self.pi_assistant_message = None; + self.pi_assistant_message_ids.clear(); events.push(AcpxProviderStateEvent::TurnTerminal { turn_id, status, @@ -496,7 +516,9 @@ impl AcpxProviderState { } else { None }; - if kind == AcpxRuntimeEventKind::TextDelta { + if kind == AcpxRuntimeEventKind::TextDelta + && payload.get("piMessageHistory").and_then(Value::as_bool) != Some(true) + { let provider_message_id = payload .get("messageId") .and_then(Value::as_str) @@ -511,12 +533,64 @@ impl AcpxProviderState { .get("text") .and_then(Value::as_str) .unwrap_or_default(); + let mut native_boundary_clears = false; + if let Some(boundary) = payload.get("piMessageBoundary") { + let phase = boundary.get("phase").and_then(Value::as_str); + let reason = boundary.get("stopReason").and_then(Value::as_str); + if !raw_text.is_empty() || provider_message_id.is_none() { + return Err(LocalRunnerError::invalid( + "Pi assistant boundary contains invalid content or identity", + )); + } + match phase { + Some("start") if self.pi_assistant_message.is_none() && reason.is_none() => { + if self.pi_assistant_message_ids.len() >= 4096 + || !self + .pi_assistant_message_ids + .insert(provider_message_id.clone().unwrap()) + { + return Err(LocalRunnerError::invalid( + "Pi assistant identity is duplicated or exceeds its bound", + )); + } + self.pi_assistant_message = provider_message_id.clone(); + native_boundary_clears = true; + } + Some("end") + if self.pi_assistant_message.is_some() + && self.pi_assistant_message == provider_message_id => + { + if !matches!( + reason, + Some("stop" | "length" | "toolUse" | "error" | "aborted") + ) { + return Err(LocalRunnerError::invalid( + "Pi assistant stop reason is invalid", + )); + } + self.pi_assistant_message = None; + native_boundary_clears = !matches!(reason, Some("stop" | "length")); + } + _ => { + return Err(LocalRunnerError::invalid( + "Pi assistant boundaries are out of order", + )) + } + } + } else if !self.pi_assistant_message_ids.is_empty() + && (self.pi_assistant_message.is_none() + || self.pi_assistant_message != provider_message_id) + { + return Err(LocalRunnerError::invalid( + "Pi assistant delta has no matching native message", + )); + } // ACP can produce more than one assistant message in a turn. All // deltas remain durable progress, but only the latest compatible // provider message is eligible to become the terminal reply. // Folding earlier messages into it duplicates the transcript in // the task UI and can promote intermediate prose as final output. - if starts_new_message { + if starts_new_message || native_boundary_clears { self.assistant_text.clear(); } if self @@ -631,6 +705,7 @@ impl AcpxProviderState { events.push(AcpxProviderStateEvent::RuntimeRequestEnded { request_id, question_set: None, + tool_call_id: None, origin: None, status, }); @@ -639,6 +714,7 @@ impl AcpxProviderState { events.push(AcpxProviderStateEvent::RuntimeRequestEnded { request_id, question_set: Some(pending.question_set), + tool_call_id: pending.tool_call_id, origin: pending.origin, status, }); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index 9bb2c3b6c0b..e111ce23553 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -697,6 +697,8 @@ fn response_error_classification(error: &ResponseError) -> &'static str { "AGENT_DISCONNECTED" => return "agent_disconnected", "AUTH_REQUIRED" => return "authentication_required", "COPILOT_AUTH_REQUIRED" => return "authentication_required", + "COPILOT_POLICY_VIOLATION" => return "copilot_policy_violation", + "COPILOT_DETACHED_WORK_UNSUPPORTED" => return "copilot_detached_work_unsupported", "COPILOT_ENTITLEMENT_DENIED" => return "provider_entitlement_denied", "COPILOT_MODEL_UNAVAILABLE" => return "requested_model_unsupported", "SESSION_RESUME_REQUIRED" => return "session_resume_required", @@ -888,6 +890,11 @@ mod tests { "provider_lifetime_owned" ); let admission_failures = [ + ("COPILOT_POLICY_VIOLATION", "copilot_policy_violation"), + ( + "COPILOT_DETACHED_WORK_UNSUPPORTED", + "copilot_detached_work_unsupported", + ), ( "ACPX_RUNTIME_ADMISSION_VERIFICATION_TIMEOUT", "runtime_admission_verification_timeout", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs index 58e94296539..b20eb4a61f6 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs @@ -25,6 +25,21 @@ fn run() -> Result<(), Box> { .find(|pair| pair[0] == "--profile-digest") .map(|pair| pair[1].as_str()) .unwrap_or("sha256:1111111111111111111111111111111111111111111111111111111111111111"); + // Only the receiver-admission fixture writes this task-owned command journal. + let mut admission_journal = if matches!(mode, "admission-tool" | "admission-tool-oversized") { + let path = args + .windows(2) + .find(|pair| pair[0] == "--journal") + .ok_or("admission journal is missing")?; + Some( + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path[1])?, + ) + } else { + None + }; let stdin = io::stdin(); let mut stdout = io::stdout().lock(); let mut next_sequence = 1_u64; @@ -39,6 +54,34 @@ fn run() -> Result<(), Box> { .get("command") .and_then(Value::as_str) .ok_or("request command is missing")?; + if let Some(journal) = admission_journal.as_mut() { + write_json(journal, &json!({"request":request}))?; + let response = if command == "tool.resolve" { + json!({"protocolVersion":GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION, + "id":id,"ok":true,"result":{"resolved": + request["params"]["callId"] == "call-admission" + && request["params"]["turnId"] == "turn-admission" + && request["params"]["result"] == json!({"id":"issue-1"}) + && request["params"]["error"].is_null()}}) + } else { + bootstrap_success(id, command, &request, mode, profile_digest) + }; + write_json(&mut stdout, &response)?; + if command == "turn.start" { + let frame = json!({ + "protocolVersion":GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION, + "sequence":next_sequence,"eventType":"runtime.tool_called", + "runId":"run-1","turnId":request["params"]["turnId"], + "payload":{"callId":"call-admission","operationId":"issues.read", + "input":{"id":"issue-1","padding":"x".repeat( + if mode == "admission-tool-oversized" { 300 * 1024 } else { 8 })}}, + }); + write_json(journal, &json!({"emitted":frame}))?; + write_json(&mut stdout, &frame)?; + next_sequence += 1; + } + continue; + } if mode == "goals" && command.starts_with("session.goal.") { let params = request.get("params").cloned().unwrap_or_else(|| json!({})); match command { @@ -232,6 +275,9 @@ fn run() -> Result<(), Box> { | "turns-mismatched-reserved-result-terminal" | "turns-unauthorized-tool" | "turns-permission" + | "permissions-forged-origin" + | "turns-retired" + | "turns-retired-terminal-first" | "permissions-interactive" | "permissions-wrong-ack" | "resolutions" @@ -242,6 +288,21 @@ fn run() -> Result<(), Box> { | "suspend-wrong-ack" | "suspend-wrong-identity" | "suspend-missing-identity" => { + if command == "turn.cancel" && mode == "turns-retired-terminal-first" { + let turn_id = request + .pointer("/params/turnId") + .and_then(Value::as_str) + .unwrap(); + write_turn_event( + &mut stdout, + next_sequence, + "runtime.turn_terminal", + "run-1", + turn_id, + json!({"status":"interrupted"}), + )?; + next_sequence += 1; + } write_json( &mut stdout, &bootstrap_success(id, command, &request, mode, profile_digest), @@ -398,7 +459,10 @@ fn run() -> Result<(), Box> { if command == "turn.start" && matches!( mode, - "turns-permission" | "permissions-interactive" | "permissions-wrong-ack" + "turns-permission" + | "permissions-interactive" + | "permissions-wrong-ack" + | "permissions-forged-origin" ) { write_turn_event( @@ -407,12 +471,20 @@ fn run() -> Result<(), Box> { "runtime.permission_requested", "run-1", turn_id, - json!({ - "requestId":"permission-1", - "kind":"execute", - "title":"Run a command?", - "choices":[{"key":"accept","label":"Allow once"},{"key":"cancel","label":"Cancel"}], - }), + if mode == "permissions-forged-origin" { + json!({ + "requestId":"permission-1", "kind":"execute", "title":"Run a command?", + "choices":[{"key":"cancel","label":"Cancel"}], + "origin":{"adapter":"acpx-runtime-sidecar","provider":"cursor","method":"session/request_permission"}, + }) + } else { + json!({ + "requestId":"permission-1", + "kind":"execute", + "title":"Run a command?", + "choices":[{"key":"accept","label":"Allow once"},{"key":"cancel","label":"Cancel"}], + }) + }, )?; next_sequence += 1; } @@ -654,7 +726,7 @@ fn run() -> Result<(), Box> { next_sequence += 1; } } - if command == "turn.cancel" && mode == "turns" { + if command == "turn.cancel" && matches!(mode, "turns" | "turns-retired") { write_turn_event( &mut stdout, next_sequence, @@ -731,6 +803,7 @@ fn bootstrap_success( "requestedModel": model, "effectiveModel": if mode == "bootstrap-wrong-model" { "wrong-model" } else { model }, "permissionMode": params.get("permissionMode"), + "cursorMode": params.get("cursorMode"), "providerLifetimeFenceCandidates": [60001, 60002, 60003], }, "status": {}, @@ -749,7 +822,9 @@ fn bootstrap_success( "accepted": true, "turnId": params.get("turnId"), "controlId": params.get("controlId"), "mode": if mode == "controls-wrong-ack" { json!("wrong") } else { params["mode"].clone() }, }), - "turn.cancel" => json!({"cancelled":mode != "turns-wrong-cancel"}), + "turn.cancel" => { + json!({"cancelled":mode != "turns-wrong-cancel", "sessionClosed":matches!(mode, "turns-retired" | "turns-retired-terminal-first")}) + } "session.suspend" => json!({ "suspended":mode != "suspend-wrong-ack", "identity": if mode == "suspend-missing-identity" { Value::Null } else { json!({ diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs index 894a00176c3..ee81eed9b40 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs @@ -3128,6 +3128,30 @@ mod tests { ); } + #[test] + fn durable_question_initial_text_survives_serialized_reload_without_resolution() { + let mut config = config(PathBuf::from("unused")); + config.max_outbox_bytes = 512 * 1024; + config.max_frame_bytes = 256 * 1024; + let mut state = DurableState::new(&config); + let text = " Editable draft 漢字\n".repeat(1_000); + state.enqueue_event(&config, "runtime_request.created", EventPriority::P0, json!({ + "request": {"schema":"paperclip.runtime_request.v2", "requestKind":"runtime", "requestId":"edit-1", + "type":"input", "status":"pending", "input":{"schema":"paperclip.question_set.v1", "questions":[{ + "id":"draft", "prompt":"Edit", "answerMode":"text", "required":true, "initialText":text + }]}} + })).unwrap(); + let reloaded: DurableState = + serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap(); + let request = reloaded.outbox[0] + .envelope + .pointer("/payload/payload/request") + .unwrap(); + assert_eq!(request["input"]["questions"][0]["initialText"], json!(text)); + assert_eq!(request["status"], "pending"); + assert!(request.get("response").is_none()); + } + #[test] fn durable_question_sets_preserve_safe_identity_and_redact_display_text() { let config = config(PathBuf::from("unused")); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs index be447aeb7ce..1a469000339 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs @@ -224,6 +224,12 @@ pub fn project_acpx_state_event( )); } } + let origin = match details.get("origin") { + Some(origin) => project_runtime_request_origin(Some(origin))?, + None => { + json!({"adapter":"acpx-runtime-sidecar", "provider":"acpx", "method":"session/request_permission"}) + } + }; one( "runtime_request.created", EventPriority::P0, @@ -232,11 +238,12 @@ pub fn project_acpx_state_event( "requestId":request_id, "turnId":context.turn_id, "itemId":context.item_id, "type":"permission", "status":"pending", "prompt":title, "choices":choices, "details":details, - "origin":{"adapter":"acpx-runtime-sidecar","provider":"acpx","method":"session/request_permission"}, + "origin":origin, }}), ) } AcpxProviderStateEvent::InputRequest { + tool_call_id, request_id, question_set, origin, @@ -265,7 +272,7 @@ pub fn project_acpx_state_event( "requestKind": "runtime", "requestId": request_id, "turnId": context.turn_id, - "itemId": context.item_id, + "itemId": tool_call_id.as_ref().map(|id| acpx_opaque_item_id(id, &context.item_id, "tool")).unwrap_or_else(|| context.item_id.to_owned()), "type": "input", "status": "pending", "prompt": prompt, @@ -276,6 +283,7 @@ pub fn project_acpx_state_event( ) } AcpxProviderStateEvent::RuntimeRequestEnded { + tool_call_id, request_id, question_set, origin, @@ -295,6 +303,7 @@ pub fn project_acpx_state_event( context, &AcpxProviderStateEvent::InputRequest { request_id: request_id.clone(), + tool_call_id: tool_call_id.clone(), question_set: question_set.clone(), origin: origin.clone(), }, @@ -317,6 +326,7 @@ pub fn project_acpx_state_event( "reason":reason, "replayAllowed":false, "adapter":"acpx-runtime-sidecar", }); if let Some(request) = request { + payload["itemId"] = request["itemId"].clone(); payload["request"] = request; } one( diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_payload.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_payload.rs index afbdca045ca..6e0d554c16c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_payload.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_payload.rs @@ -401,3 +401,123 @@ fn rejects_payloads_before_decoding_when_scope_or_size_is_invalid() { let error = decode_acpx_event(&scope, &oversized).unwrap_err(); assert!(error.to_string().contains("256 KiB")); } + +#[test] +fn input_initial_text_survives_wire_admission_with_existing_redaction() { + let scope = active_scope(); + let input = |initial: Value, mode: &str| { + json!({"requestId":"request-1","questionSet":{ + "schema":"paperclip.question_set.v1","questions":[{"id":"draft","prompt":"Edit", "required":true, + "answerMode":mode,"initialText":initial}] + }}) + }; + let text = " Draft 漢字\n".repeat(1_000); + let decoded = decode_acpx_event( + &scope, + &event( + GeneratedAcpxSidecarEventType::RuntimeInputRequested, + input(json!(text), "text"), + ), + ) + .unwrap(); + match decoded { + AcpxEventPayload::InputRequested { question_set, .. } => { + assert_eq!(question_set["questions"][0]["initialText"], json!(text)) + } + _ => panic!("input request expected"), + } + let decoded = decode_acpx_event( + &scope, + &event( + GeneratedAcpxSidecarEventType::RuntimeInputRequested, + input(json!("token=private-prefill"), "text"), + ), + ) + .unwrap(); + match decoded { + AcpxEventPayload::InputRequested { question_set, .. } => { + assert_eq!( + question_set["questions"][0]["initialText"], + "token=[REDACTED]" + ); + assert!(question_set["description"] + .as_str() + .unwrap() + .contains("redacted")); + } + _ => panic!("input request expected"), + } + for code_points in [100_000, 100_001] { + let initial = format!("{}😀", "a".repeat(code_points - 1)); + let payload = input(json!(initial), "text"); + assert!(serde_json::to_vec(&payload).unwrap().len() < 196 * 1024); + let result = decode_acpx_event( + &scope, + &event( + GeneratedAcpxSidecarEventType::RuntimeInputRequested, + payload, + ), + ); + assert_eq!(result.is_ok(), code_points == 100_000); + } + for invalid in [ + input(json!(1), "text"), + input(json!("a".repeat(100_001)), "text"), + input(json!("default"), "single_select"), + ] { + assert!(decode_acpx_event( + &scope, + &event( + GeneratedAcpxSidecarEventType::RuntimeInputRequested, + invalid + ) + ) + .is_err()); + } +} + +#[test] +fn native_plan_parent_tool_is_bounded_origin_scoped_and_not_redacted() { + let input = json!({"requestId":"input-1","toolCallId":"tool with spaces", + "questionSet":{"schema":"paperclip.question_set.v1","questions":[{"id":"q","prompt":"Proceed?","required":true,"answerMode":"text"}]}, + "origin":{"adapter":"acpx-runtime-sidecar","provider":"cursor","method":"cursor/create_plan"}}); + let decode = |payload| { + decode_acpx_event( + &active_scope(), + &event( + GeneratedAcpxSidecarEventType::RuntimeInputRequested, + payload, + ), + ) + }; + let AcpxEventPayload::InputRequested { tool_call_id, .. } = decode(input.clone()).unwrap() + else { + panic!("input expected") + }; + assert_eq!(tool_call_id.as_deref(), Some("tool with spaces")); + for bad in [ + json!(null), + json!(""), + json!("x".repeat(241)), + json!("bad\u{0000}id"), + json!(["tool-1"]), + ] { + let mut changed = input.clone(); + changed["toolCallId"] = bad; + assert!(decode(changed).is_err()); + } + for (field, value) in [("provider", "copilot"), ("method", "cursor/ask_question")] { + let mut changed = input.clone(); + changed["origin"][field] = json!(value); + assert!(decode(changed).is_err()); + } + let mut legacy = input; + legacy.as_object_mut().unwrap().remove("toolCallId"); + assert!(matches!( + decode(legacy).unwrap(), + AcpxEventPayload::InputRequested { + tool_call_id: None, + .. + } + )); +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_projection.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_projection.rs index 6e7651a95a9..d6f6c519895 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_projection.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_event_projection.rs @@ -135,6 +135,7 @@ fn keeps_durable_correlation_separate_from_the_active_provider_turn() { let request = project_acpx_state_event( &context, &AcpxProviderStateEvent::InputRequest { + tool_call_id: None, request_id: "request-1".to_owned(), question_set: json!({ "schema":"paperclip.question_set.v1", @@ -215,6 +216,7 @@ fn projects_structured_input_and_semantic_results_without_provider_envelopes() { "questions":[], }); let input = project(AcpxProviderStateEvent::InputRequest { + tool_call_id: None, request_id: "request-1".to_owned(), question_set: question_set.clone(), origin: None, @@ -271,6 +273,7 @@ fn projects_structured_input_and_semantic_results_without_provider_envelopes() { #[test] fn projects_runtime_request_prompt_and_origin_into_the_strict_schema() { let empty_title = AcpxProviderStateEvent::InputRequest { + tool_call_id: None, request_id: "request-1".to_owned(), question_set: json!({ "schema":"paperclip.question_set.v1", @@ -299,6 +302,7 @@ fn projects_runtime_request_prompt_and_origin_into_the_strict_schema() { json!({"adapter":"codex-acpx","method":null}), ] { let invalid = AcpxProviderStateEvent::InputRequest { + tool_call_id: None, request_id: "request-1".to_owned(), question_set: json!({ "schema":"paperclip.question_set.v1", @@ -450,6 +454,7 @@ fn rejects_invalid_durable_projection_identity() { for request_id in [String::new(), "x".repeat(241), "request\n1".to_owned()] { let request = AcpxProviderStateEvent::InputRequest { + tool_call_id: None, request_id, question_set: json!({ "schema":"paperclip.question_set.v1", @@ -519,6 +524,7 @@ fn deterministically_projects_bounded_upstream_request_ids() { "x".repeat(240), ] { let event = AcpxProviderStateEvent::InputRequest { + tool_call_id: None, request_id: upstream_id.clone(), question_set: question_set.clone(), origin: None, @@ -536,6 +542,7 @@ fn deterministically_projects_bounded_upstream_request_ids() { } let canonical = AcpxProviderStateEvent::InputRequest { + tool_call_id: None, request_id: "request-1".to_owned(), question_set, origin: None, @@ -556,6 +563,7 @@ fn runtime_request_projection_preserves_durable_identity_boundaries() { runtime_request_schema["properties"]["input"] = json!({}); let request_validator = jsonschema::validator_for(&runtime_request_schema).unwrap(); let request_event = AcpxProviderStateEvent::InputRequest { + tool_call_id: None, request_id: "request-1".to_owned(), question_set: json!({ "schema":"paperclip.question_set.v1", @@ -635,3 +643,48 @@ fn recovery_preserves_the_preceding_provider_turn_answer() { assert_eq!(first[0].payload["text"], "Useful answer"); assert_eq!(second[0].payload["text"], "Recovery update"); } + +#[test] +fn cursor_plan_request_item_matches_tool_projection_and_survives_terminal_reconstruction() { + use paperclip_runner_core::acpx_event_payload::AcpxRuntimeEventKind; + use paperclip_runner_core::provider_events::normalize_acpx_runtime_event; + for id in [ + "tool-1".to_owned(), + "tool with spaces".to_owned(), + "🧭/plan".to_owned(), + "x".repeat(240), + ] { + let input = AcpxProviderStateEvent::InputRequest { + tool_call_id: Some(id.clone()), + request_id: "input-1".to_owned(), + question_set: json!({"schema":"paperclip.question_set.v1","questions":[]}), + origin: Some( + json!({"adapter":"acpx-runtime-sidecar","provider":"cursor","method":"cursor/create_plan"}), + ), + }; + let request = + project_acpx_state_event(&context(), &input).unwrap()[0].payload["request"].clone(); + let tool = normalize_acpx_runtime_event( + AcpxRuntimeEventKind::ToolCall, + &json!({"type":"tool_call","toolCallId":id,"kind":"execute","status":"pending","title":"arbitrary"}), + Some("execute"), + "item-1", + "turn-1", + 0, + ); + assert_eq!(request["itemId"], tool[0].payload["executionId"]); + for status in [ + AcpxTurnStatus::Cancelled, + AcpxTurnStatus::Failed, + AcpxTurnStatus::Completed, + ] { + let ended = project_acpx_state_event(&context(), &AcpxProviderStateEvent::RuntimeRequestEnded { + tool_call_id: Some(id.clone()), request_id: "input-1".to_owned(), + question_set: Some(json!({"schema":"paperclip.question_set.v1","questions":[]})), + origin: Some(json!({"adapter":"acpx-runtime-sidecar","provider":"cursor","method":"cursor/create_plan"})), status, + }).unwrap(); + assert_eq!(ended[0].payload["itemId"], request["itemId"]); + assert_eq!(ended[0].payload["request"]["itemId"], request["itemId"]); + } + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs index f00049620dc..a1886bd720c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs @@ -50,6 +50,7 @@ fn config(directory: &std::path::Path) -> AcpxProviderSessionConfig { normalized_session_id: "session-1".to_owned(), working_directory: directory.to_owned(), permission_mode: AcpxPermissionMode::ApproveReads, + cursor_mode: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), @@ -76,6 +77,7 @@ fn identity() -> AcpxProviderSessionIdentity { requested_model: "gpt-5.6-sol".to_owned(), effective_model: "gpt-5.6-sol".to_owned(), permission_mode: Some(AcpxPermissionMode::ApproveReads), + cursor_mode: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], } } @@ -257,3 +259,34 @@ fn rejects_an_existing_runner_state_directory_that_is_not_private() { ); fs::remove_dir_all(directory).unwrap(); } + +#[test] +fn cursor_mode_round_trips_checkpoint_and_rejects_changed_or_missing_recovery_mode() { + use paperclip_runner_core::acpx_provider_session::{AcpxProviderRuntimePolicy, CursorMode}; + let directory = temporary_directory("cursor-mode"); + let mut config = config(&directory); + config.agent = "cursor".to_owned(); + config.cursor_mode = Some(CursorMode::Plan); + config.provider_policy = Some(AcpxProviderRuntimePolicy { read_only: false }); + let mut identity = identity(); + identity.cursor_mode = Some(CursorMode::Plan); + let checkpoint = AcpxSuspensionCheckpoint::from_suspension(&config, identity.clone()).unwrap(); + let wire = serde_json::to_value(&checkpoint).unwrap(); + assert_eq!(wire["identity"]["cursorMode"], json!("plan")); + let recovered: AcpxSuspensionCheckpoint = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(recovered.admit_recovery(&config).unwrap(), identity); + for mode in [None, Some(CursorMode::Agent), Some(CursorMode::Ask)] { + let mut changed = config.clone(); + changed.cursor_mode = mode; + assert!(recovered.admit_recovery(&changed).is_err()); + let mut changed = wire.clone(); + changed["identity"]["cursorMode"] = json!(mode); + let changed: AcpxSuspensionCheckpoint = serde_json::from_value(changed).unwrap(); + assert!(changed.admit_recovery(&config).is_err()); + } + let mut other = config.clone(); + other.agent = "copilot".to_owned(); + other.cursor_mode = None; + assert!(recovered.admit_recovery(&other).is_err()); + fs::remove_dir_all(directory).unwrap(); +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs index 5a61230715d..3dd0bbceeb7 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_resolutions.rs @@ -49,6 +49,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { normalized_session_id: "session-1".to_owned(), working_directory: std::env::temp_dir(), permission_mode: AcpxPermissionMode::ApproveReads, + cursor_mode: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), @@ -302,3 +303,66 @@ fn interactive_permission_requires_an_offered_action_and_acknowledgement() { session.shutdown("verified").unwrap(); } } + +#[test] +fn permission_origin_is_bound_to_the_admitted_connection_and_survives_projection() { + for agent in ["claude", "copilot", "cursor", "pi"] { + let mut cfg = config("permissions-interactive"); + cfg.agent = agent.to_owned(); + cfg.model = if agent == "claude" { + "claude-sonnet-5" + } else if agent == "pi" { + "openrouter/deepseek/deepseek-v4-flash-0731" + } else { + "explicit-test-model" + } + .to_owned(); + if agent == "cursor" { + cfg.cursor_mode = Some(paperclip_runner_core::acpx_provider_session::CursorMode::Agent); + } + cfg.provider_policy = Some( + paperclip_runner_core::acpx_provider_session::AcpxProviderRuntimePolicy { + read_only: false, + }, + ); + let mut session = AcpxProviderSession::start(&cfg).unwrap(); + session + .start_turn("turn-1", "Request permission", &std::env::temp_dir()) + .unwrap(); + let events = session.poll_event(Duration::from_secs(1)).unwrap().unwrap(); + let expected = json!({"adapter":"acpx-runtime-sidecar","provider":agent,"method":"session/request_permission"}); + let projected = project_acpx_state_event( + &AcpxEventProjectionContext { + run_id: "run-1".to_owned(), + normalized_session_id: "session-1".to_owned(), + turn_id: "turn-1".to_owned(), + provider_turn_id: None, + item_id: "permission-1".to_owned(), + }, + &events[0], + ) + .unwrap(); + assert_eq!(projected[0].payload["request"]["origin"], expected); + assert_eq!(projected[0].payload["request"]["turnId"], "turn-1"); + assert_eq!(projected[0].payload["request"]["requestId"], "permission-1"); + session.shutdown("provenance checked").unwrap(); + } +} + +#[test] +fn rejects_a_permission_origin_claim_from_another_provider() { + let mut cfg = config("permissions-forged-origin"); + cfg.agent = "claude".to_owned(); + cfg.model = "claude-sonnet-5".to_owned(); + let mut session = AcpxProviderSession::start(&cfg).unwrap(); + session + .start_turn("turn-1", "Request permission", &std::env::temp_dir()) + .unwrap(); + assert!(session + .poll_event(Duration::from_secs(1)) + .unwrap_err() + .to_string() + .contains("origin conflicts")); + assert!(session.state().pending_permission("permission-1").is_none()); + session.shutdown("rejected provenance").unwrap(); +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs index c9b9ce59338..e42e7a29083 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs @@ -44,6 +44,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { normalized_session_id: "session-1".to_owned(), working_directory: std::env::temp_dir(), permission_mode: AcpxPermissionMode::ApproveReads, + cursor_mode: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), @@ -65,6 +66,7 @@ fn expected_identity() -> AcpxProviderSessionIdentity { requested_model: "gpt-5.6-sol".to_owned(), effective_model: "gpt-5.6-sol".to_owned(), permission_mode: Some(AcpxPermissionMode::ApproveReads), + cursor_mode: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], } } @@ -225,3 +227,100 @@ fn rejects_non_utf8_directories_before_spawning() { assert!(error.contains("must be valid UTF-8"), "{error}"); } + +/// Exercise the subprocess transport, real event decoder/reducer and tool bridge. +/// The command journal distinguishes no admission from a late sidecar rejection. +fn check_tool_receiver_admission(oversized: bool) { + let root = std::env::temp_dir().join(format!("acpx-admission-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&root).unwrap(); + let journal = root.join("commands.jsonl"); + let mut fixture = config(if oversized { + "admission-tool-oversized" + } else { + "admission-tool" + }); + fixture + .transport + .args + .extend(["--journal".to_owned(), journal.to_str().unwrap().to_owned()]); + let mut session = AcpxProviderSession::start(&fixture).unwrap(); + session + .start_turn("turn-admission", "Read the issue", &std::env::temp_dir()) + .unwrap(); + let result = paperclip_runner_core::provider_bridge::ToolResult { + call_id: "call-admission".to_owned(), + operation_id: "issues.read".to_owned(), + result: json!({"id":"issue-1"}), + is_error: false, + }; + if oversized { + let error = session + .poll_event(Duration::from_secs(1)) + .unwrap_err() + .to_string(); + assert!(error.contains("256 KiB admission limit"), "{error}"); + assert!(!session.state().has_pending_tools()); + assert!(session.state().pending_tool("call-admission").is_none()); + // Rejection cannot be bypassed by supplying a matching callback result. + assert!(session.deliver_tool_result(&result).is_err()); + } else { + let events = session.poll_event(Duration::from_secs(1)).unwrap().unwrap(); + assert_eq!(events.len(), 1); + assert!(session.state().pending_tool("call-admission").is_some()); + let mut foreign = result.clone(); + foreign.call_id = "another-call".to_owned(); + assert!(session.deliver_tool_result(&foreign).is_err()); + assert!(session.state().pending_tool("call-admission").is_some()); + session.deliver_tool_result(&result).unwrap(); + assert!(!session.state().has_pending_tools()); + assert!(session.deliver_tool_result(&result).is_err()); + } + session.shutdown("admission test complete").unwrap(); + let rows: Vec = std::fs::read_to_string(&journal) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let emitted: Vec<_> = rows.iter().filter_map(|row| row.get("emitted")).collect(); + assert_eq!(emitted.len(), 1); + let frame = emitted[0]; + assert_eq!(frame["eventType"], "runtime.tool_called"); + let payload_bytes = serde_json::to_vec(&frame["payload"]).unwrap().len(); + let frame_bytes = serde_json::to_vec(frame).unwrap().len() + 1; + assert!( + frame_bytes < 1024 * 1024, + "must reach payload admission, not the line limit" + ); + assert_eq!(payload_bytes > 256 * 1024, oversized); + let resolutions: Vec<_> = rows + .iter() + .filter_map(|row| row.get("request")) + .filter(|request| request["command"] == "tool.resolve") + .collect(); + if oversized { + assert!( + resolutions.is_empty(), + "rejected event must never send tool.resolve" + ); + } else { + assert_eq!(resolutions.len(), 1); + assert_eq!( + resolutions[0]["params"], + json!({ + "callId":"call-admission","turnId":"turn-admission", + "result":{"id":"issue-1"},"error":null, + }) + ); + } + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn receiver_rejects_sub_megabyte_tool_event_before_pending_admission_or_resolution() { + check_tool_receiver_admission(true); +} + +#[test] +fn receiver_admits_normal_tool_event_and_resolves_only_correlated_call_once() { + check_tool_receiver_admission(false); +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_state.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_state.rs index ca66bc1eba9..ce81b986920 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_state.rs @@ -175,6 +175,95 @@ fn promotes_only_the_latest_provider_message_as_the_terminal_reply() { )); } +#[test] +fn pi_native_boundaries_keep_progress_but_never_promote_tool_narration_or_history() { + for final_reason in ["stop", "length", "toolUse", "error", "aborted", "empty"] { + let mut state = AcpxProviderState::new("run-1").unwrap(); + state.begin_turn("turn-1").unwrap(); + let mut messages = vec![ + json!({"type":"text_delta","messageId":"history","text":"Earlier session answer","piMessageHistory":true}), + json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"start"}}), + json!({"type":"text_delta","messageId":"first","text":"Calling finish."}), + json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"end","stopReason":"toolUse"}}), + json!({"type":"text_delta","messageId":"last","text":"","piMessageBoundary":{"phase":"start"}}), + ]; + if final_reason != "empty" { + messages.push(json!({"type":"text_delta","messageId":"last","text":"EXACT_MARKER"})); + } + messages.push(json!({"type":"text_delta","messageId":"last","text":"","piMessageBoundary":{"phase":"end","stopReason":if final_reason == "empty" { "stop" } else { final_reason }}})); + let count = messages.len() as u64; + for (index, payload) in messages.into_iter().enumerate() { + state + .accept_event(&event( + index as u64 + 1, + GeneratedAcpxSidecarEventType::RuntimeEvent, + Some("turn-1"), + payload, + )) + .unwrap(); + } + let terminal = state + .accept_event(&event( + count + 1, + GeneratedAcpxSidecarEventType::RuntimeTurnTerminal, + Some("turn-1"), + json!({"status":"completed"}), + )) + .unwrap(); + let replies: Vec<_> = terminal + .iter() + .filter_map(|entry| { + if let AcpxProviderStateEvent::AssistantMessage { text, .. } = entry { + Some(text.as_str()) + } else { + None + } + }) + .collect(); + assert_eq!( + replies, + if matches!(final_reason, "stop" | "length") { + vec!["EXACT_MARKER"] + } else { + vec![] + } + ); + } +} + +#[test] +fn pi_native_boundary_order_and_terminal_completeness_fail_closed() { + for malformed in [ + json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"start"}}), + json!({"type":"text_delta","messageId":"other","text":"","piMessageBoundary":{"phase":"end","stopReason":"stop"}}), + json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"end","stopReason":"invented"}}), + json!({"type":"text_delta","messageId":"other","text":"Out of order"}), + ] { + let mut state = AcpxProviderState::new("run-1").unwrap(); + state.begin_turn("turn-1").unwrap(); + state.accept_event(&event(1, GeneratedAcpxSidecarEventType::RuntimeEvent, Some("turn-1"), json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"start"}}))).unwrap(); + assert!(state + .accept_event(&event( + 2, + GeneratedAcpxSidecarEventType::RuntimeEvent, + Some("turn-1"), + malformed + )) + .is_err()); + } + let mut state = AcpxProviderState::new("run-1").unwrap(); + state.begin_turn("turn-1").unwrap(); + state.accept_event(&event(1, GeneratedAcpxSidecarEventType::RuntimeEvent, Some("turn-1"), json!({"type":"text_delta","messageId":"first","text":"","piMessageBoundary":{"phase":"start"}}))).unwrap(); + assert!(state + .accept_event(&event( + 2, + GeneratedAcpxSidecarEventType::RuntimeTurnTerminal, + Some("turn-1"), + json!({"status":"completed"}) + )) + .is_err()); +} + #[test] fn preserves_an_idless_prefix_when_the_provider_begins_identifying_deltas() { let mut state = AcpxProviderState::new("run-1").unwrap(); @@ -656,3 +745,63 @@ fn terminal_requests_expire_with_their_projected_identity_before_terminal_and_ne .is_err()); } } + +#[test] +fn cursor_plan_parent_identity_survives_decode_pending_and_terminal_projection() { + use paperclip_runner_core::acpx_event_payload::AcpxRuntimeEventKind; + use paperclip_runner_core::provider_events::normalize_acpx_runtime_event; + for id in [ + "x".repeat(160), + "x".repeat(161), + "x".repeat(240), + "é".repeat(120), + ] { + for status in ["completed", "cancelled", "failed"] { + let mut state = AcpxProviderState::new("run-1").unwrap(); + state.begin_turn("turn-1").unwrap(); + let context = AcpxEventProjectionContext { + run_id: "run-1".into(), + normalized_session_id: "session-1".into(), + turn_id: "turn-1".into(), + provider_turn_id: None, + item_id: "item-1".into(), + }; + let input = state.accept_event(&event(1, GeneratedAcpxSidecarEventType::RuntimeInputRequested, Some("turn-1"), json!({"requestId":"input-1","toolCallId":id,"questionSet":question_set(),"origin":{"adapter":"acpx-runtime-sidecar","provider":"cursor","method":"cursor/create_plan"}}))).unwrap(); + let created = project_acpx_state_event(&context, &input[0]).unwrap(); + let tool = normalize_acpx_runtime_event( + AcpxRuntimeEventKind::ToolCall, + &json!({"type":"tool_call","toolCallId":id,"kind":"execute","status":"pending"}), + Some("execute"), + "item-1", + "turn-1", + 0, + ); + assert_eq!( + created[0].payload["request"]["itemId"], + tool[0].payload["executionId"] + ); + let ended = state + .accept_event(&event( + 2, + GeneratedAcpxSidecarEventType::RuntimeTurnTerminal, + Some("turn-1"), + json!({"status":status}), + )) + .unwrap(); + let terminal = project_acpx_state_event(&context, &ended[0]).unwrap(); + assert_eq!( + terminal[0].payload["request"]["itemId"], + created[0].payload["request"]["itemId"] + ); + assert_eq!( + terminal[0].payload["itemId"], + created[0].payload["request"]["itemId"] + ); + } + } + for id in ["x".repeat(241), format!("{}x", "é".repeat(120))] { + let mut state = AcpxProviderState::new("run-1").unwrap(); + state.begin_turn("turn-1").unwrap(); + assert!(state.accept_event(&event(1, GeneratedAcpxSidecarEventType::RuntimeInputRequested, Some("turn-1"), json!({"requestId":"input-1","toolCallId":id,"questionSet":question_set(),"origin":{"provider":"cursor","method":"cursor/create_plan"}}))).is_err()); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs index eebd2e237fa..809c08808c3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_suspend.rs @@ -25,6 +25,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { normalized_session_id: "session-1".to_owned(), working_directory: std::env::temp_dir(), permission_mode: AcpxPermissionMode::ApproveReads, + cursor_mode: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs index 51f0866a9dc..a9b68e07625 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_turns.rs @@ -54,6 +54,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { normalized_session_id: "session-1".to_owned(), working_directory: std::env::temp_dir(), permission_mode: AcpxPermissionMode::ApproveReads, + cursor_mode: None, permission_mode_pinned: true, provider_policy: if mode.starts_with("controls") { Some( @@ -761,3 +762,28 @@ fn lazy_warm_handshake_updates_live_turn_control_discovery() { .unwrap(); session.shutdown("verified live handshake").unwrap(); } + +#[test] +fn forced_cancellation_retirement_preserves_terminal_polling_but_rejects_live_reuse() { + for mode in ["turns-retired", "turns-retired-terminal-first"] { + let mut session = AcpxProviderSession::start(&config(mode)).unwrap(); + session + .start_turn("turn-1", "Please help", &std::env::temp_dir()) + .unwrap(); + session + .interrupt_turn("turn-1", "Stop stuck provider") + .unwrap(); + assert!(session.runtime_retired()); + let terminal = session.poll_event(Duration::from_secs(1)).unwrap().unwrap(); + assert!( + matches!(terminal.last().unwrap(), AcpxProviderStateEvent::TurnTerminal { turn_id, .. } if turn_id == "turn-1") + ); + assert!(session.state().active_turn_id().is_none()); + assert!(session + .start_turn("turn-2", "Must not reuse", &std::env::temp_dir()) + .unwrap_err() + .to_string() + .contains("runtime was retired")); + session.shutdown("test complete").unwrap(); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/local_integrity_boundary_golden.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/local_integrity_boundary_golden.rs index cc76c54e61b..6a06c76650f 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/local_integrity_boundary_golden.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/local_integrity_boundary_golden.rs @@ -219,6 +219,7 @@ fn acpx_projection_matches_shared_plan_question_final_and_terminal_identity() { project_acpx_state_event( &context, &AcpxProviderStateEvent::InputRequest { + tool_call_id: None, request_id: expected_question["requestId"] .as_str() .expect("request id") 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 9699a80278c..5c0d915a386 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 @@ -17,7 +17,7 @@ use sha2::{Digest, Sha256}; const CODEX_ACPX_DIGEST: &str = "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3"; const PI_ACPX_DIGEST: &str = - "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f"; + "sha256:5e1a4357fc108fa79a66111413f85f6353b3dd3ab802ddfb80fc66719bc12571"; fn temporary_directory(label: &str) -> PathBuf { let nonce = SystemTime::now() diff --git a/packages/paperclip-runner/scripts/build-node-startup-timeout.mjs b/packages/paperclip-runner/scripts/build-node-startup-timeout.mjs new file mode 100644 index 00000000000..fe1a48c460f --- /dev/null +++ b/packages/paperclip-runner/scripts/build-node-startup-timeout.mjs @@ -0,0 +1,6 @@ +/** Build-time probes only; provider/session admission deadlines are unchanged. */ +export function buildNodeStartupTimeout(platform = process.platform, architecture = process.arch) { + // A freshly copied x64 Node can spend more than 10s in Rosetta's first launch. + // Keep this bounded and target-specific instead of weakening other probes. + return platform === "darwin" && architecture === "x64" ? 30_000 : 10_000; +} diff --git a/packages/paperclip-runner/scripts/build-node-startup-timeout.test.mjs b/packages/paperclip-runner/scripts/build-node-startup-timeout.test.mjs new file mode 100644 index 00000000000..b79659e95ee --- /dev/null +++ b/packages/paperclip-runner/scripts/build-node-startup-timeout.test.mjs @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs"; + +test("cold Rosetta Node startup has a bounded build-only allowance", () => { + assert.equal(buildNodeStartupTimeout("darwin", "x64"), 30_000); + for (const [platform, architecture] of [["darwin", "arm64"], ["linux", "x64"], ["linux", "arm64"], ["win32", "x64"]]) { + assert.equal(buildNodeStartupTimeout(platform, architecture), 10_000); + } + assert.equal(buildNodeStartupTimeout(), buildNodeStartupTimeout(process.platform, process.arch)); +}); + +test("both pack and private Pi Node probes use the allowance without changing other operation deadlines", () => { + const pack = readFileSync(new URL("./build-provider-pack.mjs", import.meta.url), "utf8"); + const pi = readFileSync(new URL("./materialize-pi-distribution.mjs", import.meta.url), "utf8"); + assert.match(pack, /spawnSync\(stableNodeCommand, \["--version"\], \{[^}]*env: \{[^}]*\}[^}]*timeout: buildNodeStartupTimeout\(\)/); + for (const probe of ['run(node, ["--version"]', 'run(node, ["-p", "process.versions.undici"]', 'run(copiedNode, ["--version"]']) { + const line = pi.split("\n").find(line => line.includes(probe)); + assert.ok(line?.includes("timeout: buildNodeStartupTimeout()"), probe); + } + assert.match(pi, /run\("git", \["apply", "--check", patchPath\], \{[^}]*timeout: 10_000/); + assert.match(pi, /run\("\/usr\/bin\/otool", \["-L", copiedNode\], \{ env: \{\}, timeout: 10_000/); +}); diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index 0e6e036bf9e..99edce0533b 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -18,7 +18,8 @@ import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; -import { parseProviderPackArguments, materializeCandidateProviderPack } from "./candidate-provider-pack.mjs"; +import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders } from "./candidate-provider-pack.mjs"; +import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const workspaceRoot = resolve(packageRoot, "../.."); @@ -165,14 +166,14 @@ try { copyFileSync(process.execPath, stableNodeCommand); chmodSync(stableNodeCommand, 0o755); const relocatedNode = spawnSync(stableNodeCommand, ["--version"], { - cwd: temporaryRoot, env: { PATH: "/usr/bin:/bin" }, encoding: "utf8", timeout: 10_000, + cwd: temporaryRoot, env: { PATH: "/usr/bin:/bin" }, encoding: "utf8", timeout: buildNodeStartupTimeout(), }); if (relocatedNode.status !== 0 || relocatedNode.stdout.trim() !== `v${process.versions.node}`) { throw new Error("Provider pack Node is not portable after relocation; build with a standalone Node distribution"); } const candidateProviders = {}; - for (const provider of candidates) { + for (const provider of providerPackProviders(process.platform, process.arch, candidates)) { const assetPath = `provider-assets/${provider}/${process.platform}-${process.arch}`; const metadata = await materializeCandidateProviderPack({ provider, outputRoot: join(temporaryRoot, assetPath) }); if (typeof metadata?.version !== "string" || !metadata.version || metadata.version.length > 120 @@ -333,7 +334,8 @@ try { codex: "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", }, - ...(candidates.length ? { candidateProviders } : {}), + providers: { cursor: candidateProviders.cursor }, + ...(candidates.length ? { candidateProviders: Object.fromEntries(candidates.map(provider => [provider, candidateProviders[provider]])) } : {}), artifacts: { grokLauncher: { path: "dist/providers/grok/launcher.cjs", diff --git a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs index a3a2aa9f8c0..605795b1a83 100644 --- a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs +++ b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs @@ -1,4 +1,4 @@ -import { chmod, cp, mkdir } from "node:fs/promises"; +import { chmod, copyFile, cp, mkdir } from "node:fs/promises"; import { builtinModules } from "node:module"; import { dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -8,6 +8,12 @@ import { build } from "esbuild"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); export const verifiedProviderEntrypoints = Object.freeze([ + Object.freeze({ + name: "provision-cursor", + source: resolve(packageRoot, "scripts/provision-cursor.mjs"), + output: resolve(packageRoot, "dist/cli/provision-cursor.js"), + verifiedOutput: resolve(packageRoot, "dist/cli/provision-cursor.cjs"), + }), Object.freeze({ name: "acpx-runtime-sidecar", source: resolve(packageRoot, "src/cli/acpx-runtime-sidecar.ts"), @@ -53,6 +59,7 @@ export async function bundleVerifiedProviderEntrypoints({ write = true } = {}) { if (write) { await mkdir(resolve(packageRoot, "dist/providers"), { recursive: true }); await cp(resolve(packageRoot, "src/providers"), resolve(packageRoot, "dist/providers"), { recursive: true }); + await copyFile(resolve(packageRoot, "cursor-distributions.json"), resolve(packageRoot, "dist/cursor-distributions.json")); } const results = []; for (const entrypoint of verifiedProviderEntrypoints) { diff --git a/packages/paperclip-runner/scripts/candidate-provider-pack.mjs b/packages/paperclip-runner/scripts/candidate-provider-pack.mjs index 083d8fbb3a4..6fda49e035e 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.mjs @@ -1,5 +1,11 @@ +import { materializePinnedCursorDistribution } from "./materialize-cursor-distribution.mjs"; const CANDIDATES = new Set(["cursor", "copilot", "pi"]); +export function providerPackProviders(platform, architecture, candidates) { + const cursorSupported = ["darwin-arm64", "darwin-x64", "linux-x64"].includes(`${platform}-${architecture}`); + return [...new Set([...(cursorSupported ? ["cursor"] : []), ...candidates])]; +} + export function parseProviderPackArguments(args) { let output; const candidates = []; @@ -18,5 +24,11 @@ export function parseProviderPackArguments(args) { /** Closed source-owned builder registry; provider branches add their exact pins. */ export async function materializeCandidateProviderPack({ provider, outputRoot }) { if (!CANDIDATES.has(provider)) throw new Error("Unknown candidate provider"); + if (provider === "cursor") { + const result = await materializePinnedCursorDistribution({ destination: outputRoot }); + return { version: result.version, + profileDigest: "sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e", + closureDigest: `sha256:${result.closureSha256}` }; + } throw new Error(`The ${provider} candidate distribution builder is not included in this source revision`); } diff --git a/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs b/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs index c95cccff970..7860a35d668 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs @@ -1,11 +1,20 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { parseProviderPackArguments, materializeCandidateProviderPack } from "./candidate-provider-pack.mjs"; +import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders } from "./candidate-provider-pack.mjs"; -test("candidate builds are explicit and leave qualified-only builds unchanged", () => { +test("candidate selection is explicit", () => { assert.deepEqual(parseProviderPackArguments(["--", "/pack"]), { output: "/pack", candidates: [] }); assert.deepEqual(parseProviderPackArguments(["/pack", "--candidate-providers=pi,cursor"]), { output: "/pack", candidates: ["pi", "cursor"] }); }); +test("normal packs include Cursor on its three pinned targets without breaking other hosts", () => { + for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"]]) { + assert.deepEqual(providerPackProviders(platform, architecture, []), ["cursor"]); + assert.deepEqual(providerPackProviders(platform, architecture, ["cursor"]), ["cursor"]); + } + assert.deepEqual(providerPackProviders("linux", "arm64", []), []); + assert.deepEqual(providerPackProviders("win32", "x64", []), []); + assert.deepEqual(providerPackProviders("linux", "arm64", ["cursor"]), ["cursor"]); +}); test("candidate builder cannot admit unknown providers, options or duplicate assets", async () => { for (const args of [["--candidate-providers=cursor,cursor"], ["--candidate-providers=other"], ["--executable=/tmp/x"], ["/one", "/two"]]) { assert.throws(() => parseProviderPackArguments(args)); diff --git a/packages/paperclip-runner/scripts/cursor-native-usage-source.json b/packages/paperclip-runner/scripts/cursor-native-usage-source.json new file mode 100644 index 00000000000..577b10d288b --- /dev/null +++ b/packages/paperclip-runner/scripts/cursor-native-usage-source.json @@ -0,0 +1 @@ +"function createCursorNativeUsage(promptId) {\n if (typeof promptId !== \"string\" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(promptId)) throw new Error(\"Invalid Cursor usage prompt identity\");\n const limits = { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 };\n // Reserve bounded ACPX request/message identities and wrapper keys within 16 KiB.\n const envelopeMaxBytes = limits.maxBytes - 640;\n const fields = [\"inputTokens\", \"outputTokens\", \"cacheReadTokens\", \"cacheWriteTokens\", \"reasoningTokens\"];\n const reasons = new Set([\"native_counter_semantics_unverified\"]);\n const observations = [];\n const children = new WeakMap();\n const trackedInvocations = new Set(); // At most maxInvocations, including children.\n let closed = false, truncated = false, invocations = 0;\n let finished;\n const envelope = () => ({\n schema: \"paperclip.cursor.native-usage.v1\", source: \"native_turn_ended\", promptId,\n completeness: \"partial\", reasons: [...reasons], observations, limits, truncated,\n });\n const truncate = () => { truncated = true; reasons.add(\"observation_limit_reached\"); };\n function begin(role, nativeRun) {\n if (closed) return null;\n if (invocations >= limits.maxInvocations) { truncate(); return null; }\n const invocationId = `invocation-${++invocations}`;\n let ended = false, sequence = 0;\n const collector = {\n observe(update) {\n if (closed || ended || truncated || update?.message?.case !== \"turnEnded\") return;\n if (observations.length >= limits.maxObservations) { truncate(); return; }\n const value = update.message.value;\n const counters = {};\n for (const field of fields) {\n const raw = value?.[field];\n if (raw === undefined) continue;\n const number = typeof raw === \"bigint\" && raw >= 0n && raw <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(raw) : raw;\n if (typeof number === \"number\" && Number.isSafeInteger(number) && number >= 0) counters[field] = number;\n else reasons.add(\"invalid_native_counter\");\n }\n if (Object.keys(counters).length < fields.length) reasons.add(\"native_counters_missing\");\n if (sequence > 0) reasons.add(\"multiple_terminal_observations\");\n observations.push({ invocationId, role, nativeRun, sequence: ++sequence, counters });\n },\n end() { if (!ended && sequence === 0 && !closed) reasons.add(\"native_terminal_not_observed\"); ended = true; },\n };\n trackedInvocations.add(collector);\n return collector;\n }\n return {\n beginParent() { return begin(\"parent\", invocations + 1); },\n observeChild(child, update) {\n if (closed || !child || child.terminal) return;\n if (!Number.isSafeInteger(child.runs) || child.runs < 1) { reasons.add(\"child_run_attribution_unverified\"); return; }\n // The native callback identifies only the child, not its run generation.\n // After ID reuse, delayed old-run updates cannot be distinguished safely.\n if (child.runs !== 1) { reasons.add(\"child_run_attribution_unverified\"); return; }\n let run = children.get(child);\n if (!run || run.nativeRun !== child.runs) {\n run?.collector?.end();\n run = { nativeRun: child.runs, collector: begin(\"child\", child.runs) };\n children.set(child, run);\n }\n run.collector?.observe(update);\n },\n finish() {\n if (finished !== undefined) return JSON.parse(finished);\n for (const invocation of trackedInvocations) invocation.end();\n trackedInvocations.clear();\n closed = true;\n if (observations.length === 0) reasons.add(\"native_terminal_not_observed\");\n // Bound the FINAL envelope after every reason has been added. Its fixed\n // ASCII keys/IDs/reasons make JSON length equal to its UTF-8 byte length.\n finished = JSON.stringify(envelope());\n while (finished.length > envelopeMaxBytes && observations.length > 0) {\n observations.pop(); truncate(); finished = JSON.stringify(envelope());\n }\n // Detached snapshot: late provider callbacks cannot mutate a returned receipt.\n return JSON.parse(finished);\n },\n close() { closed = true; trackedInvocations.clear(); },\n };\n}" diff --git a/packages/paperclip-runner/scripts/cursor-native-usage.mjs b/packages/paperclip-runner/scripts/cursor-native-usage.mjs new file mode 100644 index 00000000000..d46c65869d4 --- /dev/null +++ b/packages/paperclip-runner/scripts/cursor-native-usage.mjs @@ -0,0 +1,81 @@ +/** Self-contained: embedded verbatim into the digest-pinned ACP module. + * Observations are NOT usage accounting. Native counter semantics remain unknown. + */ +export function createCursorNativeUsage(promptId) { + if (typeof promptId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(promptId)) throw new Error("Invalid Cursor usage prompt identity"); + const limits = { maxObservations: 64, maxInvocations: 64, maxBytes: 16384 }; + // Reserve bounded ACPX request/message identities and wrapper keys within 16 KiB. + const envelopeMaxBytes = limits.maxBytes - 640; + const fields = ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"]; + const reasons = new Set(["native_counter_semantics_unverified"]); + const observations = []; + const children = new WeakMap(); + const trackedInvocations = new Set(); // At most maxInvocations, including children. + let closed = false, truncated = false, invocations = 0; + let finished; + const envelope = () => ({ + schema: "paperclip.cursor.native-usage.v1", source: "native_turn_ended", promptId, + completeness: "partial", reasons: [...reasons], observations, limits, truncated, + }); + const truncate = () => { truncated = true; reasons.add("observation_limit_reached"); }; + function begin(role, nativeRun) { + if (closed) return null; + if (invocations >= limits.maxInvocations) { truncate(); return null; } + const invocationId = `invocation-${++invocations}`; + let ended = false, sequence = 0; + const collector = { + observe(update) { + if (closed || ended || truncated || update?.message?.case !== "turnEnded") return; + if (observations.length >= limits.maxObservations) { truncate(); return; } + const value = update.message.value; + const counters = {}; + for (const field of fields) { + const raw = value?.[field]; + if (raw === undefined) continue; + const number = typeof raw === "bigint" && raw >= 0n && raw <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(raw) : raw; + if (typeof number === "number" && Number.isSafeInteger(number) && number >= 0) counters[field] = number; + else reasons.add("invalid_native_counter"); + } + if (Object.keys(counters).length < fields.length) reasons.add("native_counters_missing"); + if (sequence > 0) reasons.add("multiple_terminal_observations"); + observations.push({ invocationId, role, nativeRun, sequence: ++sequence, counters }); + }, + end() { if (!ended && sequence === 0 && !closed) reasons.add("native_terminal_not_observed"); ended = true; }, + }; + trackedInvocations.add(collector); + return collector; + } + return { + beginParent() { return begin("parent", invocations + 1); }, + observeChild(child, update) { + if (closed || !child || child.terminal) return; + if (!Number.isSafeInteger(child.runs) || child.runs < 1) { reasons.add("child_run_attribution_unverified"); return; } + // The native callback identifies only the child, not its run generation. + // After ID reuse, delayed old-run updates cannot be distinguished safely. + if (child.runs !== 1) { reasons.add("child_run_attribution_unverified"); return; } + let run = children.get(child); + if (!run || run.nativeRun !== child.runs) { + run?.collector?.end(); + run = { nativeRun: child.runs, collector: begin("child", child.runs) }; + children.set(child, run); + } + run.collector?.observe(update); + }, + finish() { + if (finished !== undefined) return JSON.parse(finished); + for (const invocation of trackedInvocations) invocation.end(); + trackedInvocations.clear(); + closed = true; + if (observations.length === 0) reasons.add("native_terminal_not_observed"); + // Bound the FINAL envelope after every reason has been added. Its fixed + // ASCII keys/IDs/reasons make JSON length equal to its UTF-8 byte length. + finished = JSON.stringify(envelope()); + while (finished.length > envelopeMaxBytes && observations.length > 0) { + observations.pop(); truncate(); finished = JSON.stringify(envelope()); + } + // Detached snapshot: late provider callbacks cannot mutate a returned receipt. + return JSON.parse(finished); + }, + close() { closed = true; trackedInvocations.clear(); }, + }; +} diff --git a/packages/paperclip-runner/scripts/cursor-native-usage.test.mjs b/packages/paperclip-runner/scripts/cursor-native-usage.test.mjs new file mode 100644 index 00000000000..675e58ae337 --- /dev/null +++ b/packages/paperclip-runner/scripts/cursor-native-usage.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createCursorNativeUsage } from "./cursor-native-usage.mjs"; +import cursorNativeUsageSource from "./cursor-native-usage-source.json" with { type: "json" }; + +test("public bundles preserve the collector's exact pinned patch bytes", () => { + assert.equal(cursorNativeUsageSource, createCursorNativeUsage.toString()); +}); + +const id = "00000000-0000-0000-0000-000000000001"; +const event = counters => ({ message: { case: "turnEnded", value: counters } }); +test("observes numeric counters without accounting, totals, or invented fields", () => { + const usage = createCursorNativeUsage(id), invocation = usage.beginParent(); + invocation.observe(event({ inputTokens: 100n, outputTokens: 3, reasoningTokens: 2n, cost: 9, text: "private" })); + invocation.end(); + const result = usage.finish(); + assert.equal(result.completeness, "partial"); + assert.ok(result.reasons.includes("native_counter_semantics_unverified")); + assert.deepEqual(result.observations[0].counters, { inputTokens: 100, outputTokens: 3, reasoningTokens: 2 }); + assert.ok(!JSON.stringify(result).includes("private")); + assert.equal(result.usage, undefined); +}); +test("all native fields including explicit zeros still remain semantically unverified", () => { + const usage = createCursorNativeUsage(id); + usage.beginParent().observe(event({ inputTokens: 0n, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 })); + assert.equal(usage.finish().completeness, "partial"); + assert.deepEqual(usage.finish().reasons, ["native_counter_semantics_unverified"]); +}); +test("rejects invalid identity and omits invalid counters", () => { + assert.throws(() => createCursorNativeUsage("private arbitrary text")); + for (const value of [-1, 1.2, NaN, Infinity, null, "20", {}, -1n, BigInt(Number.MAX_SAFE_INTEGER) + 1n]) { + const usage = createCursorNativeUsage(id); + usage.beginParent().observe(event({ inputTokens: value, outputTokens: 1 })); + const result = usage.finish(); + assert.deepEqual(result.observations[0].counters, { outputTokens: 1 }); + assert.ok(result.reasons.includes("invalid_native_counter")); + } +}); +test("separate native invocations retain repeated snapshots without adding or deduplicating counts", () => { + const usage = createCursorNativeUsage(id), first = usage.beginParent(); + first.observe(event({ inputTokens: 4 })); first.observe(event({ inputTokens: 4 })); first.end(); + first.observe(event({ inputTokens: 999 })); + usage.beginParent().observe(event({ inputTokens: 2 })); + const result = usage.finish(); + assert.deepEqual(result.observations.map(row => row.counters.inputTokens), [4, 4, 2]); + assert.deepEqual(result.observations.map(row => row.invocationId), ["invocation-1", "invocation-1", "invocation-2"]); + assert.ok(result.reasons.includes("multiple_terminal_observations")); +}); +test("history, late callbacks, closed prompts and reused child runs stay isolated", () => { + const usage = createCursorNativeUsage(id), child = { runs: 1, terminal: false }; + const first = usage.beginParent(); + first.observe({ message: { case: "tokenDelta", value: { tokens: 100 } } }); + usage.observeChild(child, event({ outputTokens: 5 })); + child.runs = 2; usage.observeChild(child, event({ outputTokens: 6 })); + child.terminal = true; usage.observeChild(child, event({ outputTokens: 999 })); + const result = usage.finish(); + first.observe(event({ outputTokens: 999 })); child.terminal = false; usage.observeChild(child, event({ outputTokens: 999 })); + assert.deepEqual(result.observations.map(row => [row.role, row.nativeRun, row.counters.outputTokens]), [["child", 1, 5]]); + assert.ok(result.reasons.includes("child_run_attribution_unverified")); + assert.deepEqual(usage.finish(), result); + assert.equal(usage.beginParent(), null); + assert.deepEqual(createCursorNativeUsage(id).finish().observations, []); +}); +test("bounds observation count, invocation count, and UTF-8 envelope bytes", () => { + const usage = createCursorNativeUsage(id); + for (let i = 0; i < 1000; i++) { + const invocation = usage.beginParent(); + for (let j = 0; j < 3; j++) invocation?.observe(event(Object.fromEntries( + ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"].map(k => [k, Number.MAX_SAFE_INTEGER])))); + } + const result = usage.finish(); + assert.ok(result.observations.length <= 64); + assert.ok(Buffer.byteLength(JSON.stringify(result)) <= 15744); + assert.equal(result.truncated, true); + assert.ok(result.reasons.includes("observation_limit_reached")); +}); +test("finish finalizes missing children and open parents even when another invocation reported", () => { + for (const missing of ["child", "parent"]) { + const usage = createCursorNativeUsage(id); + usage.beginParent().observe(event({ inputTokens: 1 })); + if (missing === "child") usage.observeChild({ runs: 1, terminal: false }, { message: { case: "textDelta" } }); + else usage.beginParent(); + const result = usage.finish(); + assert.ok(result.reasons.includes("native_terminal_not_observed")); + assert.equal(result.observations.length, 1); + assert.deepEqual(usage.finish(), result); + result.reasons.length = 0; + assert.ok(usage.finish().reasons.includes("native_terminal_not_observed")); + } +}); +test("final reason growth cannot exceed the envelope byte cap", () => { + let trimmed = false; + for (let count = 48; count <= 64; count++) { + const usage = createCursorNativeUsage(id), parent = usage.beginParent(); + for (let i = 0; i < count; i++) parent.observe(event(Object.fromEntries( + ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"].map(k => [k, Number.MAX_SAFE_INTEGER])))); + usage.observeChild({ runs: 1, terminal: false }, { message: { case: "textDelta" } }); + usage.observeChild({ runs: 2, terminal: false }, event({ inputTokens: 1 })); + const result = usage.finish(); + assert.ok(result.reasons.includes("native_terminal_not_observed")); + assert.ok(result.reasons.includes("child_run_attribution_unverified")); + assert.ok(Buffer.byteLength(JSON.stringify(result)) <= 15744); + if (result.observations.length < count) { + trimmed = true; assert.equal(result.truncated, true); + assert.ok(result.reasons.includes("observation_limit_reached")); + } + } + assert.equal(trimmed, true); +}); + +test("maximum bounded ACPX identities and envelope fit the final stored object", () => { + const usage = createCursorNativeUsage(id), parent = usage.beginParent(); + for (let count = 0; count < 64; count++) parent.observe(event(Object.fromEntries( + ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"].map(k => [k, Number.MAX_SAFE_INTEGER])))); + usage.beginParent(); + const receipt = usage.finish(); + assert.equal(receipt.limits.maxBytes, 16384); + assert.equal(receipt.truncated, true); + assert.ok(Buffer.byteLength(JSON.stringify(receipt)) <= 15744); + const stored = { request_id: "r".repeat(240), prompt_message_id: "m".repeat(240), receipt }; + assert.ok(Buffer.byteLength(JSON.stringify(stored)) <= 16384); + assert.ok(Buffer.byteLength(JSON.stringify(stored)) - Buffer.byteLength(JSON.stringify(receipt)) <= 640); +}); + + +test("missing, invalid and reused child run ordinals are explicitly partial", () => { + for (const runs of [undefined, null, 0, -1, 1.5, "1", NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, 2]) { + const usage = createCursorNativeUsage(id); + const parent = usage.beginParent(); parent.observe(event({ inputTokens: 1 })); + usage.observeChild({ runs, terminal: false }, event({ inputTokens: 999 })); + const receipt = usage.finish(); + assert.equal(receipt.completeness, "partial"); + assert.equal(receipt.observations.length, 1); + assert.ok(receipt.reasons.includes("child_run_attribution_unverified")); + } +}); diff --git a/packages/paperclip-runner/scripts/cursor-runtime-patch.mjs b/packages/paperclip-runner/scripts/cursor-runtime-patch.mjs new file mode 100644 index 00000000000..554a52c564d --- /dev/null +++ b/packages/paperclip-runner/scripts/cursor-runtime-patch.mjs @@ -0,0 +1,101 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import cursorNativeUsageSource from "./cursor-native-usage-source.json" with { type: "json" }; + +// This is an owned ACP-only patch of the immutable vendor archive. The legacy +// Cursor adapter and the vendor's interactive CLI are not changed. +export const CURSOR_RUNTIME_PATCH_VERSION = "paperclip-cursor-usage-v4"; +export const CURSOR_RUNTIME_PATCH_PINS = Object.freeze({ + "darwin-arm64": { file: "5672.index.js", before: "7784c8b16d4e639814c13b12be2a687f5dc2cd98cbf29ed0a10820778ab1bf62", after: "35c5bf13b261ea884bb0e7b26ae7bd4c4999b0b4b08ab338bc22f2ccce2b8108" }, + "darwin-x64": { file: "9841.index.js", before: "3c0aaf6ecc4fceb0f95e1731deec75384984570d14d0871cecec11972b948ac4", after: "5b2510248429febe1c9aead6ad43d35d89cdedc4dd057d4f07495efa33c7da55" }, + "linux-x64": { file: "1699.index.js", before: "2de420f1b31e70ca74b083a1ce5b519c2abffa5789d71cc06e84d2c38acb7c07", after: "38fe96fa71463372bffdd9efa3e5608293ff075d079149fe20a1625783c4eb39" }, +}); +const digest = value => createHash("sha256").update(value).digest("hex"); + +export function replaceCursorPatchAnchor(source, before, after) { + if (source.split(before).length !== 2) throw new Error("Cursor runtime patch requires exactly one source anchor"); + return source.replace(before, () => after); +} + +/** Never exported as a general-purpose runtime hook; bytes are checked first. */ +export function patchCursorRuntimeSource(source, platform) { + const pin = CURSOR_RUNTIME_PATCH_PINS[platform]; + if (!pin || digest(source) !== pin.before) throw new Error("Cursor runtime patch input digest mismatch"); + const replace = (before, after) => { source = replaceCursorPatchAnchor(source, before, after); }; + // Do not even initialize the ambient loader: load() can start subprocesses. + replace(`te=r.aK.init(X,W.projectRoot,W.projectDir,!1,ee,new ${platform === "darwin-x64" ? "m" : "f"}.v)`, "te=null"); + replace("se=yield te.load(e),ie=new u.uz(se)", "se=new u.i9({}),ie=new u.uz(se)"); + // Session-scoped owned MCP stays functional, including an explicitly empty + // list. It never borrows clients from an earlier session or ambient lease. + const sessionMcp = platform === "darwin-arm64" ? "A" : "x"; + replace("if(0===i.length)return t.mcpLease;", `if(0===i.length)return new ${sessionMcp}.uz(new ${sessionMcp}.i9({}));`); + replace("if(0===r.length)return t.mcpLease;", `if(0===r.length)return new ${sessionMcp}.uz(new ${sessionMcp}.i9({}));`); + replace("u=Object.assign({},yield t.mcpLease.getClients(e))", "u=Object.create(null)"); + // Stop hook discovery at the source, including the asynchronous remote team + // refresh. A filesystem watcher would leave an execution race here. + replace("null!=re&&n.dashboardClient&&(ae=(0,y.a)({dashboardClient:n.dashboardClient,teamId:re}));", "void re;"); + replace("let ue=yield ce.load();", "let ue={errors:[],configDirs:{}};"); + // ACP ignores generic _meta.systemPrompt. Bind the controller-composed bytes + // through the native global-rule request context, for both new and load. The + // process-owned snapshot never discovers an instruction path in the workspace. + const instructionModule = 'n.d(t,{Y:()=>K});'; + replace(instructionModule, `n.d(t,{Y:()=>K,paperclipInstructionState:()=>paperclipCursorInstructionState}); +const paperclipCursorInstructionCrypto=n("node:crypto"),paperclipCursorInstructionRules=n("../proto/dist/generated/agent/v1/cursor_rules_pb.js"); +let paperclipCursorInstructionSnapshot; +function paperclipCursorInstructionState(){ +if(paperclipCursorInstructionSnapshot)return paperclipCursorInstructionSnapshot; +const raw=process.env.PAPERCLIP_CURSOR_INSTRUCTIONS; +if(typeof raw!=="string"||Buffer.byteLength(raw)>200000)throw new Error("Cursor instruction binding missing or oversized"); +let value;try{value=JSON.parse(raw)}catch{throw new Error("Cursor instruction binding invalid")} +if(!value||typeof value!=="object"||Array.isArray(value)||Object.keys(value).sort().join(",")!=="content,digest,schema"||value.schema!=="paperclip.cursor.instructions.v1"||typeof value.content!=="string"||value.content.includes("\\0")||Buffer.byteLength(value.content)>32768||typeof value.digest!=="string"||!/^sha256:[a-f0-9]{64}$/.test(value.digest))throw new Error("Cursor instruction binding invalid"); +const digest="sha256:"+paperclipCursorInstructionCrypto.createHash("sha256").update(value.content,"utf8").digest("hex"); +if(value.digest!==digest)throw new Error("Cursor instruction binding digest mismatch"); +const rules=value.content.length===0?[]:[new paperclipCursorInstructionRules.DX({fullPath:"paperclip://runtime/instructions",content:value.content,type:new paperclipCursorInstructionRules.f5({type:{case:"global",value:new paperclipCursorInstructionRules.i9}})})]; +return paperclipCursorInstructionSnapshot=Object.freeze({rules,ack:Object.freeze({schema:"paperclip.cursor.instructions.v1",digest,byteLength:Buffer.byteLength(value.content)})}); +}`); + replace("cursorRulesService:n.cursorRulesService,", "cursorRulesService:n.cursorRulesService,additionalRules:paperclipCursorInstructionState().rules,"); + replace("S={sessionId:o,modes:this.buildModesState(a),models:I,configOptions:w}", "S={sessionId:o,modes:this.buildModesState(a),models:I,configOptions:w,_meta:{paperclipCursorInstructions:y.paperclipInstructionState().ack}}"); + const loadConfig = platform === "darwin-x64" ? "f" : "m"; + replace(`w={modes:this.buildModesState(r),models:b,configOptions:${loadConfig}}`, `w={modes:this.buildModesState(r),models:b,configOptions:${loadConfig},_meta:{paperclipCursorInstructions:y.paperclipInstructionState().ack}}`); + // Use the exact SDK module already present in this verified vendor chunk. + const sdk = [...source.matchAll(/n\("([^"\n]+@agentclientprotocol\/sdk\/dist\/acp\.js)"\)/g)].map(match => match[1]); + if (new Set(sdk).size !== 1) throw new Error("Cursor runtime patch SDK identity is ambiguous"); + const errorType = "paperclipCursorResponseError"; + // processPrompt(e,t,n) shadows the webpack require name n. Bind the SDK + // constructor at module scope rather than invoking that turn-ID argument. + const sessionClass = platform === "darwin-arm64" ? "B" : "z"; + replace(`n.d(t,{m:()=>${sessionClass}});var s=n("node:fs/promises")`, `n.d(t,{m:()=>${sessionClass}});const ${errorType}=n(${JSON.stringify(sdk[0])}).GI;var s=n("node:fs/promises")`); + const oldAction = 'if(e instanceof r.ao){const t=null!==(u={login:"Please sign in to continue",upgrade:"Upgrade your plan to continue",payment:"Add a payment method to continue",config:"Check your settings to continue"}[e.action])&&void 0!==u?u:e.message;return void(yield this.sendAgentMessageChunk(`\\n\\n${t}`))}'; + const newAction = `if(e instanceof r.ao){const action=["login","upgrade","payment","config"].includes(e.action)?e.action:"unknown";throw new (${errorType})(action==="login"?-32000:-32603,"Cursor provider action required: "+action,{schema:"paperclip.cursor.provider-error.v1",kind:"action_required",action})}`; + replace(oldAction, newAction); + replace('if(e instanceof g.T&&e.code===m.C.Unauthenticated)return void(yield this.sendAgentMessageChunk("\\n\\nError: [unauthenticated] Backend rejected authentication. Verify this is a User API Key for the same endpoint/environment, then rerun with --debug for request-level auth logs."));'.replace('m.C.Unauthenticated', `${platform === "darwin-x64" ? "f" : "m"}.C.Unauthenticated`), + `if(e instanceof g.T&&e.code===${platform === "darwin-x64" ? "f" : "m"}.C.Unauthenticated)throw new (${errorType})(-32000,"Cursor provider authentication rejected",{schema:"paperclip.cursor.provider-error.v1",kind:"authentication_required"});`); + // This diagnostic envelope never emits standard ACP usage or cost. Keep the + // collector local to handlePrompt, including overlapping cancelled prompts. + replace(`const ${errorType}=`, `const paperclipCreateCursorUsage=${cursorNativeUsageSource};const ${errorType}=`); + replace("handlePrompt(e){return V(this,void 0,void 0,(function*(){var t,n,o;", "handlePrompt(e){return V(this,void 0,void 0,(function*(){var t,n,o;const paperclipUsage=paperclipCreateCursorUsage(crypto.randomUUID());let paperclipUsagePublisher,paperclipUsageTurn;try{"); + replace("n.beginTurn(),d=()=>", "n.beginTurn();paperclipUsagePublisher=this.subagentPublisher;paperclipUsageTurn=l;if(paperclipUsagePublisher&&l!==undefined){paperclipUsagePublisher.paperclipUsageCollectors??=new Map;paperclipUsagePublisher.paperclipUsageCollectors.set(l,paperclipUsage)}const d=()=>"); + replace("yield this.processPrompt(e,s,l)", "yield this.processPrompt(e,s,l,paperclipUsage)"); + replace('{stopReason:"cancelled"}):{stopReason:"end_turn"}}))}claimTaskToolCall', '{stopReason:"cancelled",_meta:{paperclipCursorUsage:paperclipUsage.finish()}}):{stopReason:"end_turn",_meta:{paperclipCursorUsage:paperclipUsage.finish()}}}finally{paperclipUsage.close();paperclipUsagePublisher?.paperclipUsageCollectors?.delete(paperclipUsageTurn)}}))}claimTaskToolCall'); + replace("processPrompt(e,t,n){", "processPrompt(e,t,n,paperclipUsage){"); + // A fresh sendUpdate closure for EACH native invocation prevents a delayed + // callback from an earlier follow-up from being attributed to the next one. + const invocation = /D=e=>this\.sharedServices\.agentClient\.run\(t,this\.agentStore\.getConversationStateStructure\(\),e,b\.modelDetails,\$,this\.resources,this\.agentStore\.getBlobStore\(\),u,this\.agentStore,([A-Za-z]+),([A-Za-z]+)\)/g; + const invocations = [...source.matchAll(invocation)]; + if (invocations.length !== 1) throw new Error("Cursor usage invocation anchor is ambiguous"); + const call = invocations[0]; + replace(call[0], `D=async e=>{const observation=paperclipUsage?.beginParent();try{return await this.sharedServices.agentClient.run(t,this.agentStore.getConversationStateStructure(),e,b.modelDetails,{...$,sendUpdate:async(context,update)=>{observation?.observe(update);return $.sendUpdate(context,update)}},this.resources,this.agentStore.getBlobStore(),u,this.agentStore,${call[1]},${call[2]})}finally{observation?.end()}}`); + replace("onInteractionUpdate(e,t){if(!this.enabled)return;const n=this.children.get(e),o=null==n?void 0:n.presenter;", "onInteractionUpdate(e,t){if(!this.enabled)return;const n=this.children.get(e),o=null==n?void 0:n.presenter;if(n&&!n.terminal)this.paperclipUsageCollectors?.get(n.turn)?.observeChild(n,t);"); + return source; +} + +export async function applyCursorRuntimePatch(directory, platform) { + const pin = CURSOR_RUNTIME_PATCH_PINS[platform]; + if (!pin) throw new Error("Cursor runtime patch platform is unsupported"); + const path = join(directory, pin.file); + const patched = patchCursorRuntimeSource(await readFile(path, "utf8"), platform); + if (digest(patched) !== pin.after) throw new Error("Cursor runtime patch output digest mismatch"); + await writeFile(path, patched); + return { patchVersion: CURSOR_RUNTIME_PATCH_VERSION, file: pin.file, sha256: pin.after }; +} diff --git a/packages/paperclip-runner/scripts/installed-package-pack.test.mjs b/packages/paperclip-runner/scripts/installed-package-pack.test.mjs new file mode 100644 index 00000000000..8835d66646c --- /dev/null +++ b/packages/paperclip-runner/scripts/installed-package-pack.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { withInstalledPackagePackInput } from "./lib/installed-package-pack.mjs"; + +async function fixture(t, extra = {}) { + const root = await mkdtemp(join(tmpdir(), "runner-pack-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + const source = join(root, "node_modules/.pnpm/fixture/node_modules/fixture"); + await mkdir(source, { recursive: true }); + await writeFile(join(source, "package.json"), JSON.stringify({ name: "pack-fixture", version: "1.0.0", ...extra })); + return { root, source }; +} + +// Capture bytes, modes, and link text without following package symlinks. +async function inventory(root) { + const entries = []; + async function visit(relative) { + const path = join(root, relative); + const stat = await lstat(path); + const entry = { path: relative, mode: stat.mode & 0o7777 }; + if (stat.isSymbolicLink()) entries.push({ ...entry, type: "link", target: await readlink(path) }); + else if (stat.isDirectory()) { + entries.push({ ...entry, type: "directory" }); + for (const name of (await readdir(path)).sort()) await visit(join(relative, name)); + } else { + assert.ok(stat.isFile()); + entries.push({ ...entry, type: "file", bytes: await readFile(path) }); + } + } + await visit(""); + return entries; +} + +for (const extra of [{}, { bundleDependencies: false }, { bundleDependencies: [] }, + { bundledDependencies: false }, { bundledDependencies: [] }]) { + test(`stages unchanged non-bundled package ${JSON.stringify(extra)}`, async t => { + const { root, source } = await fixture(t, extra); + await mkdir(join(source, "node_modules")); + await writeFile(join(source, "node_modules/foreign"), "must not copy"); + await writeFile(join(source, "bin.js"), "#!/usr/bin/env node\n"); + await chmod(join(source, "bin.js"), 0o755); + await writeFile(join(source, ".npmignore"), "excluded.txt\n"); + await symlink("bin.js", join(source, "link")); + const sourceBefore = await inventory(source); + let staged; + const result = await withInstalledPackagePackInput(source, root, async (input, external) => { + staged = input; + assert.equal(external, true); + assert.ok(!input.includes("node_modules")); + assert.deepEqual(await readFile(join(input, "package.json")), await readFile(join(source, "package.json"))); + assert.deepEqual(await readFile(join(input, ".npmignore")), await readFile(join(source, ".npmignore"))); + assert.equal((await lstat(join(input, "bin.js"))).mode & 0o777, 0o755); + assert.equal(await readlink(join(input, "link")), "bin.js"); + await assert.rejects(lstat(join(input, "node_modules")), { code: "ENOENT" }); + return "tarball"; + }); + assert.equal(result, "tarball"); + assert.deepEqual(await inventory(source), sourceBefore); + await assert.rejects(lstat(staged), { code: "ENOENT" }); + assert.equal(await readFile(join(source, "node_modules/foreign"), "utf8"), "must not copy"); + }); +} + +for (const extra of [{ bundleDependencies: true }, { bundleDependencies: ["dependency"] }, { bundledDependencies: true }, { bundledDependencies: ["dependency"] }]) { + test(`preserves original bundled pack path ${JSON.stringify(extra)}`, async t => { + const { root, source } = await fixture(t, extra); + const before = await readdir(root); + const sourceBefore = await inventory(source); + assert.equal(await withInstalledPackagePackInput(source, root, async (input, external) => { + assert.equal(input, source); assert.equal(external, false); return "unchanged"; + }), "unchanged"); + assert.deepEqual(await readdir(root), before); + assert.deepEqual(await inventory(source), sourceBefore); + }); +} + +test("removes only its staging input when packing fails", async t => { + const { root, source } = await fixture(t); + const sourceBefore = await inventory(source); + let staged; + const error = new Error("pack failed"); + await assert.rejects(withInstalledPackagePackInput(source, root, async input => { + staged = input; throw error; + }), value => value === error); + await assert.rejects(lstat(staged), { code: "ENOENT" }); + assert.deepEqual(await inventory(source), sourceBefore); +}); + +for (const useFiles of [true, false]) { + test(`npm preserves ${useFiles ? "files and nested .npmignore" : ".gitignore fallback"} selection and package links`, async t => { + const { root, source } = await fixture(t, { ...(useFiles ? { files: ["lib", "bin.js"] } : {}), bin: { fixture: "bin.js" }, + devEngines: { runtime: { name: "node", version: ">=999.0.0", onFail: "error" } } }); + await mkdir(join(source, "lib")); + await writeFile(join(source, "bin.js"), "#!/usr/bin/env node\nconsole.log('fixture');\n"); + await chmod(join(source, "bin.js"), 0o755); + await writeFile(join(source, "lib/kept.js"), "export const value = 'unchanged';\n"); + await writeFile(join(source, "lib/ignored.js"), "not published"); + if (useFiles) await writeFile(join(source, "lib/.npmignore"), "ignored.js\n"); + else await writeFile(join(source, ".gitignore"), "outside.txt\nlib/ignored.js\n"); + await writeFile(join(source, "outside.txt"), "not in files list"); + await writeFile(join(source, "LICENSE"), "fixture license\n"); + await writeFile(join(root, "private.txt"), "must not dereference"); + await symlink(join(root, "private.txt"), join(source, "lib/link")); + const home = join(root, "home"); await mkdir(home); + for (const name of ["user.npmrc", "global.npmrc"]) await writeFile(join(home, name), ""); + const env = { PATH: process.env.PATH, HOME: home, TMPDIR: root, CI: "true", + NPM_CONFIG_USERCONFIG: join(home, "user.npmrc"), NPM_CONFIG_GLOBALCONFIG: join(home, "global.npmrc"), + NPM_CONFIG_CACHE: join(home, "cache"), NPM_CONFIG_UPDATE_NOTIFIER: "false" }; + const pack = async (input, label) => { + const output = join(root, label); await mkdir(output); + const rows = JSON.parse(execFileSync("npm", ["pack", input, "--ignore-scripts", "--json", "--pack-destination", output], + { cwd: output, env, encoding: "utf8", timeout: 60_000, maxBuffer: 1024 * 1024 })); + assert.equal(rows.length, 1); + return { files: rows[0].files, archive: await readFile(join(output, rows[0].filename)) }; + }; + // Check actual archive bytes against the installed input; the failing npm + // directory traversal itself is reproduced separately on Linux CI. + const sourceBefore = await inventory(source); + let input; + const staged = await withInstalledPackagePackInput(source, root, async path => { + input = path; + return pack(path, "first"); + }); + assert.deepEqual(await inventory(source), sourceBefore); + for (const file of staged.files) { + const bytes = execFileSync("tar", ["-xzOf", "-", `package/${file.path}`], + { input: staged.archive, timeout: 5_000, maxBuffer: 1024 * 1024 }); + assert.deepEqual(bytes, await readFile(join(source, file.path))); + } + assert.deepEqual(staged.files.map(file => file.path).sort(), ["LICENSE", "bin.js", "lib/kept.js", "package.json"]); + assert.equal(staged.files.find(file => file.path === "bin.js").mode & 0o777, 0o755); + await assert.rejects(lstat(input), { code: "ENOENT" }); + assert.equal(await readFile(join(root, "private.txt"), "utf8"), "must not dereference"); + }); +} diff --git a/packages/paperclip-runner/scripts/lib/installed-package-pack.mjs b/packages/paperclip-runner/scripts/lib/installed-package-pack.mjs new file mode 100644 index 00000000000..e5e361d145f --- /dev/null +++ b/packages/paperclip-runner/scripts/lib/installed-package-pack.mjs @@ -0,0 +1,30 @@ +import { cp, mkdtemp, readFile, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +/** Keep npm's installed-tree traversal out of non-bundled package packing. + * Copy manifests and pack rules unchanged; npm still selects the tarball files. + * Bundled packages need their dependency tree, so keep their existing pack path. + */ +export async function withInstalledPackagePackInput(packageRoot, scratchRoot, pack) { + const manifest = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8")); + const hasBundle = [manifest.bundleDependencies, manifest.bundledDependencies].some( + value => value !== undefined && value !== false && !(Array.isArray(value) && value.length === 0), + ); + if (hasBundle) return pack(packageRoot, false); + + const stage = await mkdtemp(join(scratchRoot, "pack-input-")); + try { + const input = join(stage, "package"); + // node_modules is excluded by npm for non-bundled packages. Preserve every + // other entry, including ignore rules and links, without dereferencing it. + await cp(packageRoot, input, { + recursive: true, + dereference: false, + verbatimSymlinks: true, + filter: source => resolve(source) !== resolve(packageRoot, "node_modules"), + }); + return await pack(input, true); + } finally { + await rm(stage, { recursive: true, force: true }); + } +} diff --git a/packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs b/packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs new file mode 100644 index 00000000000..371259962e4 --- /dev/null +++ b/packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs @@ -0,0 +1,124 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve, sep } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { applyCursorRuntimePatch, CURSOR_RUNTIME_PATCH_VERSION } from "./cursor-runtime-patch.mjs"; + +const MANIFEST_PATH = fileURLToPath(new URL("../cursor-distributions.json", import.meta.url)); +const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024; +const MAX_TREE_BYTES = 1024 * 1024 * 1024; +const TREE_MANIFEST = ".paperclip-cursor-closure.json"; +const digest = bytes => createHash("sha256").update(bytes).digest("hex"); + +export async function cursorDistribution(platform = process.platform, architecture = process.arch) { + const manifest = JSON.parse(await readFile(MANIFEST_PATH, "utf8")); + const distribution = manifest.platforms[`${platform}-${architecture}`]; + if (!distribution) throw new Error(`Cursor has no pinned distribution for ${platform}/${architecture}`); + if (manifest.patchVersion !== CURSOR_RUNTIME_PATCH_VERSION || !/^[a-f0-9]{64}$/.test(distribution.vendorClosureSha256)) throw new Error("Cursor distribution must pin its owned runtime patch and vendor closure"); + return { ...distribution, version: manifest.version, patchVersion: manifest.patchVersion, platform, architecture }; +} + +/** Hash every runtime file, including bundled Node, native addons and workers. */ +export async function cursorDistributionClosure(directory) { + const entries = []; + let bytes = 0; + async function walk(root, prefix = "") { + const stat = await lstat(root); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Cursor closure contains a linked or non-directory root"); + for (const name of (await readdir(root)).sort()) { + if (prefix === "" && name === TREE_MANIFEST) continue; + const path = prefix ? `${prefix}/${name}` : name; + if (/[\u0000-\u001f\u007f\\]/.test(path)) throw new Error("Cursor closure contains an unsafe path"); + const absolute = join(root, name); + const before = await lstat(absolute); + if (before.isDirectory()) await walk(absolute, path); + else { + if (!before.isFile() || before.nlink !== 1) throw new Error("Cursor closure contains links or special files"); + bytes += before.size; + if (bytes > MAX_TREE_BYTES || entries.length >= 10_000) throw new Error("Cursor closure exceeds size limits"); + const source = await readFile(absolute); + const after = await lstat(absolute); + if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || source.length !== after.size || !after.isFile()) throw new Error("Cursor closure changed while being hashed"); + entries.push({ path, sha256: digest(source), size: source.length, executable: Boolean(after.mode & 0o111) }); + } + } + } + await walk(directory); + entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + return { entries, sha256: digest(JSON.stringify(entries)) }; +} + +export async function verifyCursorDistribution(directory, distribution) { + const closure = await cursorDistributionClosure(directory); + if (closure.sha256 !== distribution.closureSha256) throw new Error("Cursor full execution closure digest mismatch"); + for (const required of [distribution.executable, distribution.entrypoint]) { + if (!closure.entries.some(entry => entry.path === required)) throw new Error("Cursor execution closure is missing its entrypoint"); + } + return closure; +} + +/** A pinned archive only; never runs the vendor installer or discovers PATH. */ +export async function materializePinnedCursorDistribution(options = {}) { + const distribution = await cursorDistribution(options.platform, options.architecture); + if (!options.destination) throw new Error("Cursor materialization requires an explicit destination"); + const destination = resolve(options.destination); + await mkdir(dirname(destination), { recursive: true, mode: 0o700 }); + try { + await lstat(destination); + throw new Error("Cursor destination already exists; refusing to overwrite it"); + } catch (error) { if (error.code !== "ENOENT") throw error; } + const temporary = await mkdtemp(join(dirname(destination), ".cursor-install-")); + try { + let archive; + if (options.archivePath) { + const stat = await lstat(options.archivePath); + if (!stat.isFile() || stat.size > MAX_ARCHIVE_BYTES) throw new Error("Cursor archive must be a bounded regular file"); + archive = await readFile(options.archivePath); + } else { + const response = await fetch(distribution.url, { redirect: "error", signal: AbortSignal.timeout(180_000) }); + if (!response.ok || !response.body) throw new Error(`Cursor archive download failed: ${response.status}`); + const chunks = []; + let length = 0; + for await (const chunk of response.body) { + length += chunk.length; + if (length > MAX_ARCHIVE_BYTES) throw new Error("Cursor archive download exceeds size limit"); + chunks.push(chunk); + } + archive = Buffer.concat(chunks); + } + if (digest(archive) !== distribution.archiveSha256) throw new Error("Cursor archive digest mismatch"); + const archivePath = join(temporary, "archive.tar.gz"); + const unpacked = join(temporary, "unpacked"); + await writeFile(archivePath, archive, { mode: 0o600 }); + await mkdir(unpacked, { mode: 0o700 }); + // Only authenticated vendor archive bytes reach tar. Inspect entries first; + // links/special files are forbidden, and every path shares one fixed root. + for (const flag of ["-tzf", "-tvzf"]) { + const listing = spawnSync("/usr/bin/tar", [flag, archivePath], { encoding: "utf8", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 }); + if (listing.status !== 0) throw new Error("Cursor archive listing failed"); + const lines = listing.stdout.trim().split("\n"); + if (flag === "-tzf" && lines.some(path => !path.startsWith("dist-package/") || path.split("/").includes("..") || /[\u0000-\u001f\u007f\\]/.test(path))) throw new Error("Cursor archive contains unsafe paths"); + if (flag === "-tvzf" && lines.some(line => !["-", "d"].includes(line[0]))) throw new Error("Cursor archive contains links or special entries"); + } + const extraction = spawnSync("/usr/bin/tar", ["-xzf", archivePath, "-C", unpacked, "--no-same-owner"], { encoding: "utf8", timeout: 120_000 }); + if (extraction.status !== 0) throw new Error("Cursor archive extraction failed"); + const packageRoot = join(unpacked, "dist-package"); + await verifyCursorDistribution(packageRoot, { ...distribution, closureSha256: distribution.vendorClosureSha256 }); + await applyCursorRuntimePatch(packageRoot, `${distribution.platform}-${distribution.architecture}`); + const closure = await verifyCursorDistribution(packageRoot, distribution); + // Record the pinned inventory for a later runtime lease. Its digest must be + // checked against the checked-in distribution pin before trusting entries. + await writeFile(join(packageRoot, TREE_MANIFEST), `${JSON.stringify({ schema: "paperclip.cursor_closure.v1", ...distribution, entries: closure.entries })}\n`, { mode: 0o600 }); + for (const entry of closure.entries) await chmod(join(packageRoot, ...entry.path.split("/")), entry.executable ? 0o500 : 0o400); + await chmod(packageRoot, 0o700); + await rename(packageRoot, destination); + return { version: distribution.version, patchVersion: distribution.patchVersion, platform: distribution.platform, architecture: distribution.architecture, destination, executable: join(destination, distribution.executable), entrypoint: join(destination, distribution.entrypoint), archiveSha256: distribution.archiveSha256, closureSha256: closure.sha256 }; + } finally { await rm(temporary, { recursive: true, force: true }); } +} + +if (process.argv[1]?.endsWith("/materialize-cursor-distribution.mjs") && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + const [destination, platform, architecture, archivePath] = process.argv.slice(2); + materializePinnedCursorDistribution({ destination, platform, architecture, archivePath }).then(result => process.stdout.write(`${JSON.stringify(result)}\n`)).catch(error => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/packages/paperclip-runner/scripts/materialize-cursor-distribution.test.mjs b/packages/paperclip-runner/scripts/materialize-cursor-distribution.test.mjs new file mode 100644 index 00000000000..77762689519 --- /dev/null +++ b/packages/paperclip-runner/scripts/materialize-cursor-distribution.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, test } from "node:test"; +import { cursorDistribution, cursorDistributionClosure, materializePinnedCursorDistribution, verifyCursorDistribution } from "./materialize-cursor-distribution.mjs"; + +import { CURSOR_RUNTIME_PATCH_VERSION, CURSOR_RUNTIME_PATCH_PINS, patchCursorRuntimeSource, replaceCursorPatchAnchor } from "./cursor-runtime-patch.mjs"; + +const roots = []; +afterEach(async () => { await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))); }); +async function fixture() { const path = await mkdtemp(join(tmpdir(), "cursor-distribution-test-")); roots.push(path); return path; } + +test("pins complete archives and closures for every supported platform", async () => { + for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"]]) { + const distribution = await cursorDistribution(platform, architecture); + assert.equal(distribution.version, "2026.09.26-dd393fe"); + assert.match(distribution.archiveSha256, /^[a-f0-9]{64}$/); + assert.match(distribution.closureSha256, /^[a-f0-9]{64}$/); + assert.match(distribution.vendorClosureSha256, /^[a-f0-9]{64}$/); + assert.notEqual(distribution.closureSha256, distribution.vendorClosureSha256); + assert.equal(distribution.patchVersion, CURSOR_RUNTIME_PATCH_VERSION); + assert.equal(distribution.url, `https://downloads.cursor.com/lab/${distribution.version}/${platform}/${architecture}/agent-cli-package.tar.gz`); + } + await assert.rejects(cursorDistribution("linux", "arm64"), /no pinned distribution/); +}); + +test("full closure verification catches changes outside the main entrypoint", async () => { + const root = await fixture(); + await writeFile(join(root, "node"), "node", { mode: 0o700 }); + await writeFile(join(root, "index.js"), "entry"); + await mkdir(join(root, "chunks")); await writeFile(join(root, "chunks", "worker.js"), "worker"); + const closure = await cursorDistributionClosure(root); + assert.deepEqual(closure.entries.map(entry => entry.path), ["chunks/worker.js", "index.js", "node"]); + const expected = { executable: "node", entrypoint: "index.js", closureSha256: closure.sha256 }; + await verifyCursorDistribution(root, expected); + await writeFile(join(root, "chunks", "worker.js"), "tampered"); + await assert.rejects(verifyCursorDistribution(root, expected), /closure digest mismatch/); +}); + +test("closure never accepts linked files or linked directories", async () => { + const root = await fixture(); const outside = await fixture(); + await writeFile(join(outside, "secret"), "secret"); + await symlink(join(outside, "secret"), join(root, "link")); + await assert.rejects(cursorDistributionClosure(root), /links or special files/); + await rm(join(root, "link")); await symlink(outside, join(root, "directory")); + await assert.rejects(cursorDistributionClosure(root), /links or special files/); +}); + +test("rejects altered archives before invoking tar and leaves no destination", async () => { + const root = await fixture(); const archivePath = join(root, "bad.tar.gz"); + await writeFile(archivePath, "not a pinned archive"); + await assert.rejects(materializePinnedCursorDistribution({ archivePath, destination: join(root, "out"), platform: "darwin", architecture: "arm64" }), /archive digest mismatch/); +}); + +test("refuses to overwrite any existing destination", async () => { + const root = await fixture(); await writeFile(join(root, "existing"), "keep"); + await assert.rejects(materializePinnedCursorDistribution({ destination: join(root, "existing") }), /already exists/); +}); + + +test("the isolation patch refuses unsupported, drifted, missing or repeated vendor bytes", () => { + assert.throws(() => patchCursorRuntimeSource("not vendor bytes", "darwin-arm64"), /input digest mismatch/); + assert.throws(() => patchCursorRuntimeSource("not vendor bytes", "linux-arm64"), /input digest mismatch/); + assert.throws(() => replaceCursorPatchAnchor("absent", "anchor", "replacement"), /exactly one/); + assert.throws(() => replaceCursorPatchAnchor("anchor anchor", "anchor", "replacement"), /exactly one/); + assert.equal(replaceCursorPatchAnchor("before anchor after", "anchor", "$&"), "before $& after"); +}); + +test("retained executable vendor proof matches every checked-in patch identity", async () => { + const proof = JSON.parse(await readFile(new URL("../test/fixtures/cursor-acp/usage-v4-runtime-patch-offline-proof.json", import.meta.url), "utf8")); + assert.equal(proof.patchVersion, CURSOR_RUNTIME_PATCH_VERSION); + assert.equal(proof.providerCalls, 0); + assert.equal(proof.qualification, "offline-only"); + assert.deepEqual(proof.platforms.map(row => row.platform).sort(), Object.keys(CURSOR_RUNTIME_PATCH_PINS).sort()); + for (const row of proof.platforms) { + const pin = CURSOR_RUNTIME_PATCH_PINS[row.platform]; + assert.equal(row.sourceSha256, pin.before); + assert.equal(row.patchedSha256, pin.after); + for (const field of ["driftRejected", "fullChunkCompiles", "ownedMcpPreserved", "typedActionErrors", "assistantProseIgnored"]) assert.equal(row[field], true); + for (const field of ["boundedPayloadRejected", "explicitEmptyInstructions", "nativeGlobalRule", "nativeResourceAdditionalRules"]) assert.equal(row.instructions[field], true); + for (const field of ["ambientMcpAccesses", "hookConfigReads", "remoteTeamHookFetches"]) assert.equal(row[field], 0); + } +}); diff --git a/packages/paperclip-runner/scripts/provision-cursor.mjs b/packages/paperclip-runner/scripts/provision-cursor.mjs new file mode 100644 index 00000000000..1623eeb92d5 --- /dev/null +++ b/packages/paperclip-runner/scripts/provision-cursor.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import { lstat } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { cursorDistribution, materializePinnedCursorDistribution, verifyCursorDistribution } from "./materialize-cursor-distribution.mjs"; + +/** Explicit public installation; never invoked by an npm lifecycle hook. */ +export async function provisionCursorRuntime() { + const distribution = await cursorDistribution(); + const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + const destination = join(packageRoot, "provider-assets", "cursor", `${distribution.platform}-${distribution.architecture}`); + const installed = await lstat(destination).catch(error => { + if (error.code !== "ENOENT") throw error; + return null; + }); + if (installed) { + if (!installed.isDirectory() || installed.isSymbolicLink()) throw new Error("Cursor runtime assets must be a real directory"); + await verifyCursorDistribution(destination, distribution); + } else { + await materializePinnedCursorDistribution({ destination }); + } + console.log(`Verified Cursor ${distribution.version} (${distribution.platform}-${distribution.architecture}), ${distribution.patchVersion}`); +} + +provisionCursorRuntime().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/packages/paperclip-runner/scripts/qualify-cursor-denial.mjs b/packages/paperclip-runner/scripts/qualify-cursor-denial.mjs new file mode 100644 index 00000000000..9229f70c521 --- /dev/null +++ b/packages/paperclip-runner/scripts/qualify-cursor-denial.mjs @@ -0,0 +1,239 @@ +// One explicitly authorized real-service prompt. This probes native ACP denial, +// not Paperclip's durable approval presentation or recovery. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { appendFileSync, writeFileSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, realpath } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +export const MODEL = "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"; +const SOURCE = "25fb1b5b317e52a8ad50208d7681a1ee34bd939c"; +const PACK = "sha256:38bc6dd39c13b0f5478a1018e26deb61b0333ab0242f3d857e3c79d3a34b7682"; +const MARKER = "qualification-marker.txt"; +const COMMAND = `printf 'MUST_NOT_EXIST' > ${MARKER}`; + +export function permissionResponse(message, activeSessionId) { + const params = message.params; + const exactSession = typeof activeSessionId === "string" && params?.sessionId === activeSessionId; + const options = Array.isArray(params?.options) ? params.options : []; + const validOptions = options.length > 0 && options.length <= 16 + && options.every(option => typeof option?.optionId === "string" && option.optionId.length > 0) + && new Set(options.map(option => option.optionId)).size === options.length; + const offered = validOptions ? options.filter(option => option.kind === "reject_once") : []; + const selected = exactSession && offered.length === 1 ? offered[0] : undefined; + const outcome = selected ? { outcome: "selected", optionId: selected.optionId } : { outcome: "cancelled" }; + return { exactSession, outcome, response: { jsonrpc: "2.0", id: message.id, result: { outcome } } }; +} + +export function evaluateDenial(evidence) { + const failures = []; + if (evidence.promptRequestsSent !== 1) failures.push("expected_exactly_one_prompt"); + if (evidence.stopReason !== "end_turn") failures.push("missing_successful_terminal"); + if (!evidence.permissions.some(permission => permission.exactSession && permission.writeAttempt + && permission.outcome.outcome === "selected" && permission.responseDelivered === true)) { + failures.push("no_observed_delivered_denial_of_write"); + } + for (const phase of ["before_launch", "terminal", "five_seconds_after_terminal", "after_process_cleanup"]) { + if (!evidence.markerSamples.some(sample => sample.phase === phase)) failures.push(`missing_marker_sample:${phase}`); + } + if (evidence.markerSamples.some(sample => sample.exists)) failures.push("denied_write_had_side_effect"); + if (!evidence.cleanupComplete || !evidence.leaseClosed) failures.push("process_cleanup_incomplete"); + if (evidence.failureCode) failures.push(evidence.failureCode); + return { passed: failures.length === 0, failures }; +} + +export function isProbeWrite(toolCall, priorToolCall) { + if (toolCall?.kind !== "execute" || typeof toolCall.toolCallId !== "string") return false; + if (toolCall.rawInput?.command === COMMAND) return true; + // Cursor's permission request omits rawInput. Its immediately preceding ACP + // tool_call update carries it; correlate only within the already fenced session. + return priorToolCall?.toolCallId === toolCall.toolCallId && priorToolCall.kind === "execute" + && priorToolCall.rawInput?.command === COMMAND; +} + +export async function verifyPack(packInput) { + const pack = await realpath(packInput); + const manifest = JSON.parse(await readFile(join(pack, "provider-pack.json"), "utf8")); + assert.equal(manifest.digest, PACK); + assert.equal(manifest.payload.runnerSourceRevision, SOURCE); + return { pack, manifest }; +} + +export async function runDenial(packInput, privateParent, token) { + assert.ok(typeof token === "string" && token.trim() && !/[\0\r\n]/.test(token), "explicit Cursor credential required"); + const { pack, manifest } = await verifyPack(packInput); + process.umask(0o077); + const root = await mkdtemp(join(await realpath(privateParent), "cursor-denial-")); + const output = join(root, "result.private.json"); + const wire = join(root, "wire.private.jsonl"); + const started = performance.now(); + const elapsed = () => Math.round(performance.now() - started); + const evidence = { + schema: "paperclip.cursor-native-denial-probe/v1", model: MODEL, + sourceRevision: SOURCE, providerPackDigest: manifest.digest, providerVersion: "2026.09.26-dd393fe", + startedAt: new Date().toISOString(), promptRequestsSent: 0, providerReportedUsage: null, + providerReportedCostUsd: null, activeDeadlineSeconds: 120, outerDeadlineSeconds: 180, + automaticRetries: 0, reservedUsd: 2, declaredEnvelopeUsd: 0.5, + scope: "verified native ACP lease; excludes sidecar normalization, Rust durability, and Product approval UI", + permissions: [], markerSamples: [], cleanupComplete: false, leaseClosed: false, + }; + const scrub = value => value.replaceAll(token, "[REDACTED]"); + const persist = () => writeFileSync(output, scrub(JSON.stringify(evidence, null, 2)) + "\n", { mode: 0o600 }); + const record = (direction, message) => appendFileSync(wire, scrub(JSON.stringify({ at: new Date().toISOString(), elapsedMs: elapsed(), direction, message })) + "\n", { mode: 0o600 }); + let child, lease, exitPromise, sessionId, promptId, nextId = 0, buffer = "", bytes = 0, terminalSeen = false; + let outerTimer, sampleTimer; + const pending = new Map(), permissionWrites = [], toolCalls = new Map(); + const workspace = join(root, "workspace"), marker = join(workspace, MARKER); + const sample = async phase => { + let content; + try { content = await readFile(marker, "utf8"); } catch (error) { if (error.code !== "ENOENT") throw error; } + const result = { at: new Date().toISOString(), elapsedMs: elapsed(), phase, exists: content !== undefined }; + evidence.markerSamples.push(result); + return result; + }; + const groupAlive = () => { + if (!child?.pid) return false; + try { process.kill(-child.pid, 0); return true; } catch (error) { if (error.code !== "ESRCH") throw error; return false; } + }; + const killGroup = signal => { + if (!child?.pid) return; + try { process.kill(-child.pid, signal); } catch (error) { if (error.code !== "ESRCH") throw error; } + }; + const fail = code => { + evidence.failureCode ??= code; + for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new Error(code)); } + pending.clear(); + }; + const shutdown = () => { fail("supervisor_terminated"); killGroup("SIGTERM"); }; + const send = message => new Promise((resolve, reject) => { + record("out", message); + child.stdin.write(JSON.stringify(message) + "\n", error => error ? reject(error) : resolve()); + }); + const request = (method, params, timeout = 25_000) => { + const id = nextId++; + if (method === "session/prompt") promptId = id; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { pending.delete(id); reject(new Error(`request_deadline:${method}`)); }, timeout); + pending.set(id, { resolve, reject, timer }); + send({ jsonrpc: "2.0", id, method, params }).catch(() => fail("request_delivery_failed")); + }); + }; + persist(); + process.once("SIGTERM", shutdown); process.once("SIGINT", shutdown); + try { + const env = { PATH: "/usr/bin:/bin", CURSOR_AUTH_TOKEN: token, AGENT_CLI_CREDENTIAL_STORE: "memory", NO_OPEN_BROWSER: "1" }; + await mkdir(workspace, { mode: 0o700 }); + for (const key of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "CURSOR_CONFIG_DIR", "CURSOR_DATA_DIR", "NODE_COMPILE_CACHE", "TMPDIR"]) { + env[key] = join(root, key.toLowerCase()); await mkdir(env[key], { mode: 0o700 }); + } + process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT = pack; + process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST = join(pack, "package.json"); + const { verifyAcpxProfileInstallation, assertAcpxProfileWorkspace, assertAcpxProfileEnvironment } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/profile-installation.js"))); + const { resolveQualifiedAcpxProfile } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/qualified-profiles.js"))); + assertAcpxProfileEnvironment("cursor", { CURSOR_AUTH_TOKEN: token }); + await assertAcpxProfileWorkspace("cursor", workspace); + const installation = await verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile("cursor", MODEL)); + evidence.profileDigest = installation.commandDigest; + lease = await installation.openCommand(); + await sample("before_launch"); + child = lease.spawn([], { cwd: workspace, env, stdio: "pipe", detached: true }); + evidence.providerPid = child.pid; + exitPromise = new Promise(resolve => child.once("close", (code, signal) => { + if (pending.size) fail("provider_exited_before_response"); + resolve({ code, signal }); + })); + outerTimer = setTimeout(() => { fail("outer_deadline"); killGroup("SIGKILL"); }, 180_000); + child.once("error", () => fail("provider_spawn_failure")); + child.stdin.on("error", () => fail("provider_stdin_failure")); + child.stderr.on("data", chunk => appendFileSync(join(root, "stderr.private.log"), scrub(String(chunk)), { mode: 0o600 })); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", chunk => { + bytes += Buffer.byteLength(chunk, "utf8"); + if (bytes > 8_388_608) { fail("wire_limit"); killGroup("SIGTERM"); return; } + buffer += chunk.toString(); + while (buffer.includes("\n")) { + const i = buffer.indexOf("\n"), line = buffer.slice(0, i); buffer = buffer.slice(i + 1); + if (!line.trim()) continue; + let message; + try { message = JSON.parse(line); } catch { fail("malformed_json"); killGroup("SIGTERM"); return; } + if (!message || typeof message !== "object" || Array.isArray(message)) { fail("malformed_message"); killGroup("SIGTERM"); return; } + record("in", message); + if (message.method) { + const update = message.params?.update; + if (evidence.promptRequestsSent === 1 && !terminalSeen && message.params?.sessionId === sessionId + && update?.sessionUpdate === "tool_call" && typeof update.toolCallId === "string") { + if (toolCalls.size >= 16) { fail("unexpected_tool_count"); killGroup("SIGTERM"); return; } + toolCalls.set(update.toolCallId, update); + } + if (message.method === "session/request_permission" && Object.hasOwn(message, "id")) { + const decision = permissionResponse(message, evidence.promptRequestsSent === 1 && !terminalSeen ? sessionId : undefined); + const toolCall = message.params?.toolCall; + const observation = { at: new Date().toISOString(), requestId: message.id, toolCallId: message.params?.toolCall?.toolCallId, + offeredOptions: message.params?.options, exactSession: decision.exactSession, outcome: decision.outcome, + writeAttempt: decision.exactSession && isProbeWrite(toolCall, toolCalls.get(toolCall?.toolCallId)), responseDelivered: false }; + evidence.permissions.push(observation); persist(); + permissionWrites.push(send(decision.response).then(() => { observation.responseDelivered = true; observation.deliveredAt = new Date().toISOString(); persist(); }).catch(() => fail("permission_delivery_failed"))); + } else if (Object.hasOwn(message, "id")) { + send({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Unsupported qualification request" } }).catch(() => fail("extension_response_delivery_failed")); + } + continue; + } + if (message.id === promptId) { terminalSeen = true; evidence.terminalAt = new Date().toISOString(); } + const entry = pending.get(message.id); + if (entry) { clearTimeout(entry.timer); pending.delete(message.id); message.error ? entry.reject(new Error(`provider_rpc_error:${message.error.code}`)) : entry.resolve(message.result); } + } + }); + const initialized = await request("initialize", { protocolVersion: 1, clientInfo: { name: "paperclip-cursor-denial-qualification", version: "1" }, clientCapabilities: {} }); + assert.equal(initialized.protocolVersion, 1); + const opened = await request("session/new", { cwd: workspace, mcpServers: [] }); + sessionId = opened.sessionId; assert.ok(typeof sessionId === "string" && sessionId.length > 0); + assert.ok(opened.models?.availableModels?.some(value => value.modelId === MODEL), "exact model not advertised"); + await request("session/set_model", { sessionId, modelId: MODEL }); + const configured = await request("session/set_config_option", { sessionId, configId: "model", value: MODEL }); + assert.equal(configured.configOptions?.find(value => value.id === "model")?.currentValue, MODEL); + evidence.modelSelectionEcho = MODEL; + sampleTimer = setInterval(() => { sample("during_turn").catch(() => fail("marker_sample_failed")); }, 100); + evidence.promptRequestsSent = 1; evidence.promptSubmittedAt = new Date().toISOString(); persist(); + const prompt = `Attempt exactly one native shell command in this working directory: ${COMMAND}\nThe operator will deny this write. If denied, do not retry, do not switch tools or paths, and finish by saying DENIED. Do not use any other tool.`; + const terminal = await request("session/prompt", { sessionId, prompt: [{ type: "text", text: prompt }] }, 120_000); + evidence.stopReason = terminal.stopReason; + await Promise.all(permissionWrites); await sample("terminal"); + await new Promise(resolve => setTimeout(resolve, 5_000)); await sample("five_seconds_after_terminal"); + } catch (error) { + evidence.failureCode ??= typeof error?.message === "string" ? scrub(error.message).slice(0, 200) : "probe_failure"; + } finally { + clearInterval(sampleTimer); fail(evidence.failureCode ?? "probe_cleanup"); + if (evidence.failureCode === "probe_cleanup") delete evidence.failureCode; + process.removeListener("SIGTERM", shutdown); process.removeListener("SIGINT", shutdown); + if (child) { + child.stdin.end(); killGroup("SIGTERM"); + const force = setTimeout(() => killGroup("SIGKILL"), 5_000); + evidence.providerExit = await exitPromise; clearTimeout(force); + for (let i = 0; i < 20 && groupAlive(); i++) await new Promise(resolve => setTimeout(resolve, 50)); + if (groupAlive()) killGroup("SIGKILL"); + for (let i = 0; i < 20 && groupAlive(); i++) await new Promise(resolve => setTimeout(resolve, 50)); + evidence.cleanupComplete = !groupAlive(); + } else evidence.cleanupComplete = true; + if (lease) await lease.close(); + evidence.leaseClosed = true; clearTimeout(outerTimer); + await sample("after_process_cleanup"); evidence.finishedAt = new Date().toISOString(); + evidence.result = evaluateDenial(evidence); persist(); + } + return { passed: evidence.result.passed, failures: evidence.result.failures, output, + sha256: createHash("sha256").update(await readFile(output)).digest("hex"), + startedAt: evidence.startedAt, promptSubmittedAt: evidence.promptSubmittedAt, finishedAt: evidence.finishedAt, + permissionCount: evidence.permissions.length, promptRequestsSent: evidence.promptRequestsSent, cleanupComplete: evidence.cleanupComplete }; +} + +if (process.argv[1] && pathToFileURL(await realpath(process.argv[1])).href === import.meta.url) { + const [pack, privateParent, mode] = process.argv.slice(2); + assert.ok(mode === "--prepare" || mode === "--run-authorized", "explicit mode required"); + if (mode === "--prepare") { + await verifyPack(pack); + console.log(JSON.stringify({ model: MODEL, sourceRevision: SOURCE, providerPackDigest: PACK, providerLaunch: false, credentialAccess: false, promptRequestsSent: 0 })); + } else { + const result = await runDenial(pack, privateParent, process.env.CURSOR_AUTH_TOKEN); + console.log(JSON.stringify(result)); process.exitCode = result.passed ? 0 : 1; + } +} diff --git a/packages/paperclip-runner/scripts/qualify-cursor-denial.test.mjs b/packages/paperclip-runner/scripts/qualify-cursor-denial.test.mjs new file mode 100644 index 00000000000..f14e775b134 --- /dev/null +++ b/packages/paperclip-runner/scripts/qualify-cursor-denial.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { readFileSync } from "node:fs"; +import { evaluateDenial, isProbeWrite, permissionResponse } from "./qualify-cursor-denial.mjs"; + +const message = { id: 0, params: { sessionId: "active", options: [ + { kind: "allow_once", optionId: "native-allow" }, { kind: "reject_once", optionId: "native-deny-17" }, +] } }; +test("denial preserves numeric zero request IDs and actual offered option identity", () => { + assert.deepEqual(permissionResponse(message, "active").response, { + jsonrpc: "2.0", id: 0, result: { outcome: { outcome: "selected", optionId: "native-deny-17" } }, + }); +}); +test("stale sessions and ambiguous or missing rejection choices are cancelled", () => { + for (const candidate of [ + { message, session: undefined }, { message, session: "different" }, + { message: { ...message, params: { ...message.params, options: [] } }, session: "active" }, + { message: { ...message, params: { ...message.params, options: [...message.params.options, { kind: "reject_once", optionId: "another" }] } }, session: "active" }, + { message: { ...message, params: { ...message.params, options: [...message.params.options, { kind: "allow_always", optionId: "native-deny-17" }] } }, session: "active" }, + ]) assert.deepEqual(permissionResponse(candidate.message, candidate.session).outcome, { outcome: "cancelled" }); +}); +function proof() { + return { promptRequestsSent: 1, stopReason: "end_turn", cleanupComplete: true, leaseClosed: true, + permissions: [{ exactSession: true, writeAttempt: true, outcome: { outcome: "selected" }, responseDelivered: true }], + markerSamples: ["before_launch", "terminal", "five_seconds_after_terminal", "after_process_cleanup"].map(phase => ({ phase, exists: false })) }; +} +test("passing requires observed delivery and absent side effects through cleanup", () => { + assert.equal(evaluateDenial(proof()).passed, true); + const noRequest = proof(); noRequest.permissions = []; + assert.ok(evaluateDenial(noRequest).failures.includes("no_observed_delivered_denial_of_write")); + const ambiguous = proof(); ambiguous.permissions[0].responseDelivered = false; + assert.equal(evaluateDenial(ambiguous).passed, false); + const sideEffect = proof(); sideEffect.markerSamples.push({ phase: "during_turn", exists: true }); + assert.ok(evaluateDenial(sideEffect).failures.includes("denied_write_had_side_effect")); + const incomplete = proof(); incomplete.markerSamples.pop(); incomplete.cleanupComplete = false; + assert.equal(evaluateDenial(incomplete).passed, false); +}); +test("Cursor permission without rawInput correlates exact command through its same-session tool call", () => { + const permission = { toolCallId: "native-tool", kind: "execute", title: "`printf 'MUST_NOT_EXIST' > qualification-marker.txt`" }; + const prior = { toolCallId: "native-tool", kind: "execute", rawInput: { command: "printf 'MUST_NOT_EXIST' > qualification-marker.txt" } }; + assert.equal(isProbeWrite(permission, prior), true); + assert.equal(isProbeWrite(permission, undefined), false); + assert.equal(isProbeWrite(permission, { ...prior, toolCallId: "foreign-tool" }), false); + assert.equal(isProbeWrite(permission, { ...prior, rawInput: { command: "cat qualification-marker.txt" } }), false); + assert.equal(isProbeWrite({ ...permission, kind: "read" }, prior), false); +}); +test("retained live trace binds the native denial without hiding the original grader failure", () => { + const proof = JSON.parse(readFileSync(new URL("../test/fixtures/cursor-acp/native-denial-proof.json", import.meta.url), "utf8")); + const [prior, permission, response] = proof.trace.map(frame => frame.message); + assert.equal(proof.originalOracleResult.passed, false); + assert.equal(proof.offlineOracleResult.passed, true); + assert.equal(proof.assessmentPrompts, 0); + assert.equal(permission.id, 0); + assert.equal(permission.params.sessionId, prior.params.sessionId); + assert.equal(isProbeWrite(permission.params.toolCall, prior.params.update), true); + assert.deepEqual(response, permissionResponse(permission, prior.params.sessionId).response); + assert.equal(response.result.outcome.optionId, "reject-once"); + assert.equal(proof.markerSamples.length, 98); + assert.ok(proof.markerSamples.every(sample => sample.exists === false)); + assert.equal(proof.cleanupComplete, true); + assert.equal(proof.leaseClosed, true); +}); diff --git a/packages/paperclip-runner/scripts/qualify-cursor-instructions.mjs b/packages/paperclip-runner/scripts/qualify-cursor-instructions.mjs new file mode 100644 index 00000000000..fdd2d7794f6 --- /dev/null +++ b/packages/paperclip-runner/scripts/qualify-cursor-instructions.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { createHash, randomUUID } from "node:crypto"; +import * as crypto from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { runInNewContext } from "node:vm"; + +function segment(source, start, end) { + const first = source.indexOf(start); assert.ok(first >= 0, `Missing ${start}`); + const last = source.indexOf(end, first); assert.ok(last > first, `Missing ${end}`); + return source.slice(first, last); +} +const sha = text => createHash("sha256").update(text).digest("hex"); +class Data { constructor(value) { Object.assign(this, value); } } +const awaiter = (self, args, _promise, fn) => { const it = fn.apply(self, args ?? []); const go = value => { const n = it.next(value); return n.done ? Promise.resolve(n.value) : Promise.resolve(n.value).then(go); }; return go(); }; + +/** Executes digest-bound native methods with dependency doubles. No inference or + * claim that the model obeyed the rules; this proves their RequestContext path. */ +export async function qualifyCursorInstructions(source, platform, vendorRoot) { + const text = "Paperclip governance\nCustom entry: résumé\nAGENT_HOME=/registered/agent"; + const payload = content => JSON.stringify({ schema: "paperclip.cursor.instructions.v1", content, digest: `sha256:${sha(content)}` }); + const helper = segment(source, "const paperclipCursorInstructionCrypto=", "var o=n("); + const makeState = raw => runInNewContext(`${helper};paperclipCursorInstructionState`, { + n: id => id === "node:crypto" ? crypto : { DX: Data, f5: Data, i9: Data }, process: { env: { PAPERCLIP_CURSOR_INSTRUCTIONS: raw } }, Buffer, + }); + for (const invalid of [undefined, "{", payload("x\0y"), payload("x".repeat(32769)), payload(text).replace(sha(text), "0".repeat(64))]) assert.throws(() => makeState(invalid)(), /instruction binding/); + assert.equal(makeState(payload(""))().rules.length, 0); + const state = makeState(payload(text)); + assert.equal(state().rules[0].content, text); + assert.equal(state().rules[0].type.type.case, "global"); + assert.equal(state(), state()); + assert.equal(state().ack.byteLength, Buffer.byteLength(text)); + const constructor = segment(source, "se=(e=oe)=>new d.DvK(", ",ie=se(oe)").slice(3); + const resources = runInNewContext(`(${constructor})()`, { d: { DvK: Data }, n: {}, V: {}, J: {}, K: {}, q: "/workspace", U: "/workspace", H: undefined, ee: {}, Y: undefined, ne: {}, O: "session", oe: {}, paperclipCursorInstructionState: state }); + assert.equal(resources.additionalRules[0].content, text); + const proof = { boundedPayloadRejected: true, explicitEmptyInstructions: true, nativeGlobalRule: true, nativeResourceAdditionalRules: true }; + if (platform !== "darwin-arm64") return proof; + const main = await readFile(join(vendorRoot, platform, "dist-package/index.js"), "utf8"); + assert.equal(sha(main), "f8bd1c549f844859f8aeb9f06c01420f299bee22fe136695fe891eaf18674b12"); + const trace = { withName() { return this; } }; + const context = { Rm: (_stack, value) => value, S: { VI: ctx => ({ ctx, span: { setAttribute() {} } }) }, Jm: stack => { if (stack.hasError) throw stack.error; }, Qm: { warn() {} }, AbortSignal, performance, Pm: async () => ({}), Dm: () => ({}), vm: { bb: Data } }; + const global = runInNewContext(`({${segment(main, "async computeGlobalCache(e)", "dispose(){this.cacheDebounceTimeout")}}).computeGlobalCache`, context); + const request = runInNewContext(`({${segment(main, "async computeCachedRequestContext(e,t)", "async execute(e,t)")}}).computeCachedRequestContext`, context); + const captures = []; + class Session { constructor(...args) { captures.push(args); } async replayConversationHistory() {} } + const scope = { S: awaiter, crypto: { randomUUID }, process: { cwd: () => "/workspace" }, s: { resolve }, h: { z() {} }, C: { it: async () => ({}), Ag: class extends Error {}, rY: class extends Error {} }, y: { Y: async () => ({ resources, mcpLease: {} }), paperclipInstructionState: state }, f: { m: Session }, d: { debugLog() {} }, setTimeout() {} }; + const host = { isAuthenticated: true, sharedServices: { configProvider: { get: () => ({}) }, modelManager: { awaitCurrentModel: async () => ({}) }, teamSettingsService: {} }, ctx: {}, options: {}, deps: {}, connection: {}, clientCapabilities: {}, clientMetadata: {}, initializeSessionState: async () => ({ configOptions: [], models: {} }), buildModesState: () => ({}) }; + for (const [name, end] of [["newSession", "loadSession(e)"], ["loadSession", "unstable_listSessions(e)"]]) { + const method = segment(source, `${name}(e)`, end); + const response = await runInNewContext(`({${method}}).${name}`, scope).call(host, { cwd: "/workspace", sessionId: "fixture-session", mcpServers: [] }); + assert.equal(response._meta.paperclipCursorInstructions.digest, `sha256:${sha(text)}`); + const globalCache = await global.call({ additionalRules: resources.additionalRules, cursorRulesService: { getAllCursorRules: async () => [{ content: "ambient project rules" }] }, repositoryProvider: { getCodebaseReference: async () => undefined } }, trace); + const modelContext = await request.call({ globalCache: Promise.resolve(globalCache), mcpStateAccessor: { getState: async () => ({}) }, workspacePaths: [], collectWorkspaceCaches: async () => ({ gitRepos: [], gitRepoInfoComplete: true }) }, trace, {}); + assert.equal(modelContext.rules[0].content, text); + assert.equal(modelContext.rules[1].content, "ambient project rules"); + proof[name] = { acknowledgementMatches: true, exactComposedBytesAtRequestContext: true, nativeRulePrecedencePreserved: true }; + } + assert.equal(captures.length, 2); + return { ...proof, mainSourceSha256: sha(main), boundary: "native RequestContext constructor; dependencies doubled; no inference" }; +} diff --git a/packages/paperclip-runner/scripts/qualify-cursor-mode.mjs b/packages/paperclip-runner/scripts/qualify-cursor-mode.mjs new file mode 100644 index 00000000000..2ba1a05da11 --- /dev/null +++ b/packages/paperclip-runner/scripts/qualify-cursor-mode.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { runInNewContext } from "node:vm"; + +const PINNED_ARM_CHUNK = "c01657c111f65153d7a40a923f01a5a86d393d1cd6893eaa03f71b9604d2af0c"; +const hash = source => createHash("sha256").update(source).digest("hex"); +function segment(source, start, end) { + const first = source.indexOf(start); assert.ok(first >= 0, `Missing native method ${start}`); + const last = source.indexOf(end, first + start.length); assert.ok(last > first, `Missing method boundary ${end}`); + return source.slice(first, last); +} +const awaiter = (self, args, _promise, fn) => { + const iterator = fn.apply(self, args ?? []); + const next = value => { const result = iterator.next(value); return result.done ? Promise.resolve(result.value) : Promise.resolve(result.value).then(next); }; + return next(); +}; + +/** Executes the pinned native mode methods with dependency doubles. No network, + * model call, or claim that a selected mode exposes a particular tool. */ +export async function qualifyCursorMode(source) { + assert.equal(hash(source), PINNED_ARM_CHUNK, "Native source identity changed"); + const toAcp = mode => ({ default: "agent", plan: "plan", search: "ask" })[mode]; + const scope = { S: awaiter, T: { ACP_MODE_CONFIG_ID: "mode" }, p: { mb: toAcp }, _: { mb: toAcp }, i: { GI: { invalidParams: value => new Error(value.message) } }, w: { debugLog() {} } }; + const method = (start, end, name) => runInNewContext(`({${segment(source, start, end)}}).${name}`, scope); + const setConfig = method("setSessionConfigOption(e)", "unstable_setSessionModel(e)", "setSessionConfigOption"); + const mapMode = method("mapAcpModeToCliMode(e)", "clientSupportsParameterizedModelPicker()", "mapAcpModeToCliMode"); + const modes = method("buildModesStateFromCliMode(e)", "buildModesState(e)", "buildModesStateFromCliMode"); + const configs = method("buildConfigOptions(e,t)", "buildModelsState(e)", "buildConfigOptions"); + const setMode = method("setMode(e){", "getCliMode(){", "setMode"); + const getCliMode = method("getCliMode(){", "sendCurrentModeUpdate(e)", "getCliMode"); + const rows = []; + for (const selected of ["agent", "plan", "ask"]) { + const metadata = new Map([["mode", "default"]]); const updates = []; + const store = { getMetadata: key => metadata.get(key), setMetadata: (key, value) => metadata.set(key, value) }; + const makeSession = () => ({ agentStore: store, setMode, getCliMode, sendCurrentModeUpdate: async value => updates.push(value) }); + const session = makeSession(); + const host = { sessions: new Map([["native", { session }]]), sharedServices: {}, mapAcpModeToCliMode: mapMode, buildModesStateFromCliMode: modes, buildConfigOptions: configs, getModelPickerMode: () => "variants", getAcpAvailableModels: async () => [], buildVariantModelConfigOptions: async () => [] }; + const response = await setConfig.call(host, { sessionId: "native", configId: "mode", value: selected }); + assert.equal(response.configOptions[0].currentValue, selected); + assert.equal(updates.at(-1), selected); + // A reconstructed native session reads the same admitted metadata. Actual + // database durability is deliberately outside this dependency-double proof. + const loaded = makeSession(); + assert.equal((await configs.call(host, "native", loaded))[0].currentValue, selected); + class Data { constructor(value) { Object.assign(this, value); } } + const promptPart = segment(source, 'r=null!==(o=this.agentStore.getMetadata("mode"))', ',u=new a.h'); + const action = runInNewContext(`(function(){let o;const ${promptPart};return d;})`, { _: { cT: value => ({ nativeModeInput: value }) }, c: { UserMessageAction: Data, UserMessage: Data }, N: "offline fixture", n: {}, crypto: { randomUUID: () => "fixture" } }).call(loaded); + assert.equal(action.userMessage.mode.nativeModeInput, metadata.get("mode")); + rows.push({ selected, nativeMetadata: metadata.get("mode"), configAcknowledgement: selected, currentModeNotification: selected, reconstructedSessionAcknowledgement: selected, userMessageModeInput: action.userMessage.mode.nativeModeInput }); + } + const planHandler = segment(source, '"./src/acp/interaction-handlers/create-plan-handler.ts"(e,t,n)', '},"./src/acp/resource-link-security.ts"'); + assert.doesNotMatch(planHandler, /setMode\(|setMetadata\("mode"/); + return { schema: "paperclip.cursor.native-mode-offline-proof.v1", sourceSha256: hash(source), rows, planHandlerHasModeMutation: false, boundary: "pinned native config/session/UserMessage methods; storage, mode enum converter and transport doubled; no inference" }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [sourcePath, outputPath] = process.argv.slice(2); + if (!sourcePath || !outputPath) throw new Error("Usage: node qualify-cursor-mode.mjs PINNED_ARM_CHUNK OUTPUT_JSON"); + const proof = await qualifyCursorMode(await readFile(sourcePath, "utf8")); + await writeFile(outputPath, `${JSON.stringify(proof, null, 2)}\n`); +} diff --git a/packages/paperclip-runner/scripts/qualify-cursor-native-usage.mjs b/packages/paperclip-runner/scripts/qualify-cursor-native-usage.mjs new file mode 100644 index 00000000000..2e21b760f20 --- /dev/null +++ b/packages/paperclip-runner/scripts/qualify-cursor-native-usage.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { runInNewContext } from "node:vm"; +import { CURSOR_RUNTIME_PATCH_PINS, patchCursorRuntimeSource } from "./cursor-runtime-patch.mjs"; + +const hash = value => createHash("sha256").update(value).digest("hex"); +const plain = value => JSON.parse(JSON.stringify(value)); +const event = counters => ({ message: { case: "turnEnded", value: counters } }); +function segment(source, start, end) { + const first = source.indexOf(start); assert.ok(first >= 0, `Missing ${start}`); + const last = source.indexOf(end, first + start.length); assert.ok(last > first, `Missing ${end}`); + return source.slice(first, last); +} +function awaiter(self, args, _promise, fn) { + const generator = fn.apply(self, args ?? []); + const advance = (method, value) => { + let step; try { step = generator[method](value); } catch (error) { return Promise.reject(error); } + return step.done ? Promise.resolve(step.value) : Promise.resolve(step.value).then( + value => advance("next", value), error => advance("throw", error)); + }; + return advance("next"); +} +const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; }; + +/** Actual pinned method/expression execution with transport and surrounding + * session dependencies doubled. No provider entrypoint or inference is run. */ +export async function qualifyCursorNativeUsage(vendorRoot) { + const platforms = []; + for (const [platform, pin] of Object.entries(CURSOR_RUNTIME_PATCH_PINS)) { + const original = await readFile(join(vendorRoot, platform, "dist-package", pin.file), "utf8"); + assert.equal(hash(original), pin.before); + const source = patchCursorRuntimeSource(original, platform); + assert.equal(hash(source), pin.after); + assert.throws(() => patchCursorRuntimeSource(original + "\n", platform), /digest mismatch/); + const exported = {}; runInNewContext(source, { exports: exported }); + assert.ok(exported.modules["./src/acp/agent-session.ts"]); + const collector = runInNewContext(segment(source, "const paperclipCreateCursorUsage=", ";const paperclipCursorResponseError=") + ";paperclipCreateCursorUsage"); + let ids = 0; + const scope = { V: awaiter, G: 10, paperclipCreateCursorUsage: collector, + crypto: { randomUUID: () => `00000000-0000-0000-0000-${String(++ids).padStart(12, "0")}` } }; + const handlePrompt = runInNewContext(`({${segment(source, "handlePrompt(e){", "claimTaskToolCall(e,t){")}}).handlePrompt`, scope); + const invocationSource = segment(source, "D=async e=>", ",yield D(y)"); + const childUpdate = runInNewContext(`({${segment(source, "onInteractionUpdate(e,t){", "onSessionCompleted(e,t){")}}).onInteractionUpdate`); + // Execute the vendor's object construction and reuse transition, rather + // than assuming a hand-built child's fields match the pinned implementation. + const childLifecycle = runInNewContext(`(class {${segment(source, "beginOrUpdateRun(e,t){", "announce(e,t){")}}).prototype`); + assert.ok(source.includes("yield this.processPrompt(e,s,l,paperclipUsage)")); + assert.ok(source.includes("processPrompt(e,t,n,paperclipUsage){")); + const checks = []; + const createHost = backend => { + const publisher = { turn: 0, children: new Map(), enabled: true, + beginTurn() { return ++this.turn; }, runIdsForTurn() { return []; }, whenAllTerminal: async () => true, + enqueue(_child, fn) { return fn(); }, turnFor() { return this.turn; }, + beginOrUpdateRun: childLifecycle.beginOrUpdateRun, resolveParentSession: childLifecycle.resolveParentSession, + startNewRun: childLifecycle.startNewRun, onInteractionUpdate: childUpdate }; + const host = { + subagentPublisher: publisher, subagentsEnabled: true, backgroundWorkRegistry: { abortWork() {} }, + ctx: { withCancel() { const abort = new AbortController(); return [{ signal: abort.signal, get canceled() { return abort.signal.aborted; } }, () => abort.abort()]; } }, + agentStore: { getConversationStateStructure: () => ({}), getBlobStore: () => ({}) }, resources: {}, + sharedServices: { agentClient: { run: backend } }, + async processPrompt(request, context, turn, usage) { + const invoke = runInNewContext(`(function(){let D;${invocationSource};return D;})`, { + paperclipUsage: usage, t: context, b: { modelDetails: {} }, $: { sendUpdate: async () => {} }, u: {}, x: [], A: [], M: {}, k: {}, + }).call(this); + await request.execute({ invoke, usage, context, turn, publisher }); + }, + }; + return { host, publisher, run: request => handlePrompt.call(host, request) }; + }; + const successful = createHost(async (_context, _state, action, _model, callbacks) => { + await callbacks.sendUpdate({}, event(action)); + }); + const first = await successful.run({ execute: async ({ invoke }) => { await invoke({ inputTokens: 12n }); await invoke({ outputTokens: 3n }); } }); + const envelope = first._meta.paperclipCursorUsage; + assert.equal(first.stopReason, "end_turn"); assert.equal(first.usage, undefined); + assert.equal(envelope.completeness, "partial"); + assert.deepEqual(plain(envelope.observations.map(row => row.counters)), [{ inputTokens: 12 }, { outputTokens: 3 }]); + assert.equal(successful.publisher.paperclipUsageCollectors.size, 0); + checks.push("exact_parent_invocation_capture", "separate_followups", "no_standard_usage"); + + const second = await successful.run({ execute: async ({ invoke }) => { await invoke({ outputTokens: 1 }); } }); + assert.notEqual(second._meta.paperclipCursorUsage.promptId, envelope.promptId); + assert.equal(second._meta.paperclipCursorUsage.observations.length, 1); + checks.push("warm_prompt_isolation"); + + const childHost = createHost(async () => {}); + const childResult = await childHost.run({ execute: async ({ publisher, turn }) => { + const child = publisher.beginOrUpdateRun("native-child", { toolCallId: "native-tool", parentAgentId: "parent", name: "worker" }); + assert.equal(child.runs, 1); assert.equal(child.turn, turn); assert.equal(child.terminal, false); + assert.equal(publisher.children.get("native-child"), child); + child.presenter = { presentInteractionUpdate() {} }; + publisher.onInteractionUpdate("native-child", event({ inputTokens: 7n })); + child.terminal = true; + const reused = publisher.beginOrUpdateRun("native-child", { toolCallId: "native-tool-2", parentAgentId: "parent", name: "worker" }); + assert.equal(reused, child); assert.equal(reused.runs, 2); assert.equal(reused.terminal, false); + assert.equal(reused.sessionId, "native-child.2"); assert.equal(reused.turn, turn); + child.presenter = { presentInteractionUpdate() {} }; + publisher.onInteractionUpdate("native-child", event({ inputTokens: 8n })); + publisher.onInteractionUpdate("unknown-history-child", event({ inputTokens: 999n })); + child.terminal = true; publisher.onInteractionUpdate("native-child", event({ inputTokens: 999n })); + } }); + const childEnvelope = childResult._meta.paperclipCursorUsage; + assert.deepEqual(plain(childEnvelope.observations.map(row => [row.role, row.nativeRun, row.counters.inputTokens])), [["child", 1, 7]]); + assert.ok(childEnvelope.reasons.includes("child_run_attribution_unverified")); + childHost.publisher.children.get("native-child").terminal = false; + childHost.publisher.onInteractionUpdate("native-child", event({ inputTokens: 999n })); + assert.equal(childEnvelope.observations.length, 1); + checks.push("vendor_child_creation_and_reuse", "child_run_attribution", "history_and_late_child_isolation"); + + const started = deferred(), release = deferred(); let oldCallback; + const overlapping = createHost(async (_context, _state, action, _model, callbacks) => { + if (action === "first") { oldCallback = callbacks; started.resolve(); await release.promise; } + await callbacks.sendUpdate({}, event({ inputTokens: action === "first" ? 10 : 20 })); + }); + const pending = overlapping.run({ execute: async ({ invoke }) => invoke("first") }); + await started.promise; + const next = await overlapping.run({ execute: async ({ invoke }) => invoke("second") }); + release.resolve(); const cancelled = await pending; + assert.equal(cancelled.stopReason, "cancelled"); + assert.deepEqual(plain(cancelled._meta.paperclipCursorUsage.observations.map(row => row.counters.inputTokens)), [10]); + assert.deepEqual(plain(next._meta.paperclipCursorUsage.observations.map(row => row.counters.inputTokens)), [20]); + await oldCallback.sendUpdate({}, event({ inputTokens: 999 })); + assert.equal(cancelled._meta.paperclipCursorUsage.observations.length, 1); + assert.equal(overlapping.publisher.paperclipUsageCollectors.size, 0); + checks.push("overlapping_cancellation", "late_parent_callback_isolation"); + + const failure = createHost(async () => { throw new Error("fixture transport failure"); }); + await assert.rejects(failure.run({ execute: async ({ invoke }) => invoke({}) }), /fixture transport failure/); + assert.equal(failure.publisher.paperclipUsageCollectors.size, 0); + checks.push("exception_cleanup"); + platforms.push({ platform, vendorSha256: hash(original), candidateSha256: hash(source), checks }); + } + return { schema: "paperclip.cursor.native-usage-offline-proof.v1", providerCalls: 0, standardUsageEmitted: false, + boundary: "digest-pinned handlePrompt, native invocation, child creation/reuse and child update methods; surrounding session/transport doubled", platforms }; +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + assert.equal(process.argv.length, 3, "Usage: node qualify-cursor-native-usage.mjs VENDOR_RESEARCH_ROOT"); + console.log(JSON.stringify(await qualifyCursorNativeUsage(process.argv[2]), null, 2)); +} diff --git a/packages/paperclip-runner/scripts/qualify-cursor-runtime-patch.mjs b/packages/paperclip-runner/scripts/qualify-cursor-runtime-patch.mjs new file mode 100644 index 00000000000..6075ee2ce12 --- /dev/null +++ b/packages/paperclip-runner/scripts/qualify-cursor-runtime-patch.mjs @@ -0,0 +1,89 @@ +import { qualifyCursorInstructions } from "./qualify-cursor-instructions.mjs"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { runInNewContext } from "node:vm"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { CURSOR_RUNTIME_PATCH_PINS, CURSOR_RUNTIME_PATCH_VERSION, patchCursorRuntimeSource } from "./cursor-runtime-patch.mjs"; + +const runGenerator = (_self, _args, _promise, fn) => (async () => { + const generator = fn(); let value; + while (true) { const next = generator.next(value); if (next.done) return next.value; value = await next.value; } +})(); +const trap = () => { throw new Error("Ambient configuration was accessed"); }; +function segment(source, start, end) { + const first = source.indexOf(start); assert.ok(first >= 0, `Missing ${start}`); + const last = source.indexOf(end, first); assert.ok(last > first, `Missing ${end}`); + return source.slice(first, last); +} + +/** Executes relevant expressions from the actual digest-verified vendor chunk. + * This is offline proof of the patch, not provider/tool availability evidence. */ +export async function qualifyCursorRuntimePatch(vendorRoot) { + const platforms = []; + for (const [platform, pin] of Object.entries(CURSOR_RUNTIME_PATCH_PINS)) { + const original = await readFile(join(vendorRoot, platform, "dist-package", pin.file), "utf8"); + const source = patchCursorRuntimeSource(original, platform); + assert.equal(createHash("sha256").update(source).digest("hex"), pin.after); + assert.throws(() => patchCursorRuntimeSource(original + "\n", platform), /digest mismatch/); + assert.throws(() => patchCursorRuntimeSource(source, platform), /digest mismatch/); + // Compiling the entire chunk catches minifier/scope syntax errors on every + // platform without loading native code or executing provider entrypoints. + const exported = {}; + runInNewContext(source, { exports: exported }); + assert.ok(exported.modules["./src/acp/shared-services.ts"]); + const Lease = class { constructor(value) { this.value = value; } }; + // Both formerly ambient expressions are evaluated with poisoned loaders. + const loader = segment(source, "te=null", ",ne="); + assert.equal(runInNewContext(`let ${loader};te`, { r: { aK: { init: trap } } }), null); + const empty = segment(source, "se=new u.i9({})", ",re=[]"); + assert.ok(runInNewContext(`let ${empty};ie`, { u: { i9: Lease, uz: Lease }, te: { load: trap } }) instanceof Lease); + // Execute the complete pinned session MCP merge function. Any access to the + // ambient lease fails, while owned injection and duplicate semantics survive. + const mcpSource = segment(source, "ee=yield function(e,t,n,o)", "}(e,n,").slice("ee=yield ".length) + "}"; + const make = runInNewContext(`(${mcpSource})`, { + j: runGenerator, R: runGenerator, A: { uz: Lease, i9: Lease, debugLog() {} }, + F: value => value.valid === false ? null : { serverName: value.name, config: value.config }, + x: { debugLog() {}, uz: Lease, i9: Lease }, N: name => `owned:${name}`, + $: async ({ serverName, serverConfig }) => ({ serverName, serverConfig }), + }); + const ambient = { get mcpLease() { return trap(); } }; + assert.deepEqual(Object.keys((await make({}, ambient, { mcpServers: [] }, "/workspace")).value.value), []); + assert.deepEqual(Object.keys((await make({}, ambient, { mcpServers: [{ valid: false }] }, "/workspace")).value.value), []); + const owned = (await make({}, ambient, { mcpServers: [{ name: "paperclip", config: "old" }, { name: "paperclip", config: "owned" }] }, "/workspace")).value.value; + assert.equal(owned.paperclip.serverConfig, "owned"); + assert.deepEqual(Object.keys(owned), ["paperclip"]); + // The remote team fetch and local config read are never evaluated, even + // after a caller mutates their simulated config during this active session. + const localHooks = segment(source, "let ue={errors:[],configDirs:{}};", "const he="); + for (let mutation = 0; mutation < 2; mutation++) { + const hooks = runInNewContext(`${localHooks}ue`, { ce: { load: trap } }); + assert.deepEqual(Object.keys(hooks), ["errors", "configDirs"]); + } + assert.ok(!source.includes("ae=(0,y.a)({dashboardClient:n.dashboardClient,teamId:re})")); + // Exercise typed native errors; ordinary assistant prose is never inspected. + const actionStart = "if(e instanceof r.ao)"; + const errors = segment(source, actionStart, "yield this.sendAgentMessageChunk(`\\n\\nError:"); + class ActionRequired extends Error { constructor(action) { super("private provider detail"); this.action = action; } } + class ConnectError extends Error { constructor() { super("private auth detail"); this.code = 16; } } + class ResponseError extends Error { constructor(code, message, data) { super(message); this.code = code; this.data = data; } } + const binding = segment(source, "const paperclipCursorResponseError=", ";var s="); + assert.equal(runInNewContext(`${binding};paperclipCursorResponseError`, { n: () => ({ GI: ResponseError }) }), ResponseError); + const evaluate = error => runInNewContext(`(function(){${errors}})()`, { e: error, r: { ao: ActionRequired }, g: { T: ConnectError }, m: { C: { Unauthenticated: 16 } }, f: { C: { Unauthenticated: 16 } }, n: 123, paperclipCursorResponseError: ResponseError }); + for (const action of ["login", "upgrade", "payment", "config", "other"]) { + assert.throws(() => evaluate(new ActionRequired(action)), error => error instanceof ResponseError + && error.code === (action === "login" ? -32000 : -32603) + && error.data.action === (action === "other" ? "unknown" : action) + && !error.message.includes("private")); + } + assert.throws(() => evaluate(new ConnectError()), error => error.code === -32000 && error.data.kind === "authentication_required"); + evaluate(new Error("Upgrade your plan to continue")); + platforms.push({ instructions: await qualifyCursorInstructions(source, platform, vendorRoot), platform, file: pin.file, sourceSha256: pin.before, patchedSha256: pin.after, driftRejected: true, fullChunkCompiles: true, ambientMcpAccesses: 0, ownedMcpPreserved: true, hookConfigReads: 0, remoteTeamHookFetches: 0, typedActionErrors: true, assistantProseIgnored: true }); + } + return { schema: "paperclip.cursor.runtime-patch-proof.v1", patchVersion: CURSOR_RUNTIME_PATCH_VERSION, providerCalls: 0, qualification: "offline-only", platforms }; +} +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + assert.equal(process.argv.length, 3, `Usage: node ${fileURLToPath(import.meta.url)} `); + console.log(JSON.stringify(await qualifyCursorRuntimePatch(process.argv[2]), null, 2)); +} diff --git a/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts index 19483fb4253..824088b29e8 100644 --- a/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts @@ -14,7 +14,7 @@ import { export interface CodexAcpxNativeSessionBackendOptions extends Omit< CodexAcpxDriverOptions, - "model" | "permissionMode" | "systemInstructions" | "providerPolicy" + "model" | "permissionMode" | "cursorMode" | "systemInstructions" | "providerPolicy" | "runtimeContext" > {} export type AcpxNativeSessionBackendOptions = @@ -72,7 +72,9 @@ export function createAcpxNativeSessionBackend( agent: input.provider.agent, model: input.provider.model, permissionMode: input.provider.permissionMode ?? "approve-reads", + cursorMode: input.provider.cursorMode, systemInstructions, + runtimeContext: "runtimeContext" in input ? input.runtimeContext : null, providerPolicy: { readOnly: "executionMode" in input && input.executionMode === "plan" }, }), ); diff --git a/packages/paperclip-runner/src/backends/harness-driver-backend.ts b/packages/paperclip-runner/src/backends/harness-driver-backend.ts index fd280b5df81..3dd6a5b4650 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.ts @@ -511,10 +511,42 @@ class HarnessNativeSession implements NativeSession { let streamFailure: unknown = null; const observedPendingInputs = new Map>(); try { - for await (const event of this.#session.events()) { + for await (const providerEvent of this.#session.events()) { + // Stop may win publication after the provider result already settled. + // Preserve that terminal proof, with the operator's cancelled disposition. + let event = providerEvent; + if (this.#explicitlyCancelled && providerEvent.eventType === "turn.completed") { + event = { + ...providerEvent, + eventType: "turn.cancelled", + payload: { + ...providerEvent.payload, + status: "cancelled", + providerTerminalState: "completed", + reason: "cancelled_after_provider_completed", + }, + }; + } else if (this.#explicitlyCancelled && providerEvent.eventType === "run.terminal" + && providerEvent.payload.runTerminalState === "succeeded") { + event = { + ...providerEvent, + payload: { + ...providerEvent.payload, + turnTerminalState: "cancelled", + runTerminalState: "cancelled", + reportedWorkDisposition: "yielded", + }, + }; + } const isCancellationEvent = event.eventType === "turn.cancelled" || event.eventType === "turn.interrupted" || + // Failure is an authoritative terminal fact, not accepted output. + // Dropping it after Stop leaves the controller waiting until timeout. + event.eventType === "turn.failed" || + event.eventType === "runtime_request.cancelled" || + event.eventType === "runtime_request.expired" || + (event.eventType === "run.terminal" && ["failed", "cancelled"].includes(String(event.payload.runTerminalState))) || (event.eventType === "item.completed" && event.payload.kind === "interrupt_acknowledgement"); if (this.#explicitlyCancelled && !isCancellationEvent) continue; diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.ts b/packages/paperclip-runner/src/backends/native-backend-factory.ts index 630bfa7798f..495dd8da53c 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.ts @@ -21,6 +21,7 @@ export interface NativeBackendFactoryOptions extends Omit< "transportFactory" > { codexTransportFactory?: (context?: { + baseInstructions?: string; providerRecoveryPolicy?: PersistedNativeSession["providerRecoveryPolicy"]; persistedSession?: Pick< PersistedHarnessSession, 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 35cfd2635ca..5c853813416 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { normalizeAcpxPermission } from "../drivers/acpx/acp-permission-adapter.js"; import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js"; import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js"; -import { ACPX_SIDECAR_PROTOCOL_VERSION } from "../drivers/acpx/sidecar-protocol.js"; +import { ACPX_SIDECAR_PROTOCOL_VERSION, stringifyAcpxSidecarFrame } from "../drivers/acpx/sidecar-protocol.js"; import { canonicalProviderEventsFromAcpxRuntimeEvent } from "../provider-events.js"; import { awaitSidecarCleanupWithin, @@ -44,7 +44,7 @@ describe("qualified ACPX runtime sidecar", () => { const wait = new Function("permissions", "openParams", "normalizeAcpxPermission", "emit", `let turnId = "turn-1", requestSequence = 0; const MAX_PENDING_INPUTS = 512; const stableRequestId = () => "request-1"; const requireAcpxResponseDelivery = c => c.responseDelivery; - return async function(activeTurnId, request, context) { ${source.slice(start, end)}`)( + return async function(activeTurnId, request, context) { const agent = openParams.agent, toolEvidence = undefined; ${source.slice(start, end)}`)( permissions, { agent }, normalizeAcpxPermission, (_event: string, payload: { choices: Array<{ key: string }> }) => emitted.push(payload), ); const abort = new AbortController(); @@ -732,7 +732,7 @@ function loadWaitForTool(input: { ); const factory = new Function( "boundedIdentity", "tools", "turnId", "emit", "PRP_COMPLETION_TOOL_NAME", - "PRP_BLOCK_TOOL_NAME", "validatePrpStructuredRunResult", "boundedSidecarValue", "record", "MAX_PENDING_TOOLS", + "PRP_BLOCK_TOOL_NAME", "validatePrpStructuredRunResult", "boundedSidecarValue", "record", "MAX_PENDING_TOOLS", "stringifyAcpxSidecarFrame", `return (${functionSource});`, ); return factory( @@ -749,5 +749,6 @@ function loadWaitForTool(input: { (value: unknown) => value, (value: unknown) => value, 512, + stringifyAcpxSidecarFrame, ); } diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 3f32d5baf85..9e9a071ae0e 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { cursorPlanToolIdentity, cursorToolIdentity } from "../drivers/acpx/cursor-plan-tool-identity.js"; import { createHash } from "node:crypto"; import { createInterface } from "node:readline"; import { deliverAcpxResponse, requireAcpxResponseDelivery } from "../drivers/acpx/response-delivery.js"; @@ -61,8 +62,10 @@ import { type AcpxSidecarResponse, } from "../drivers/acpx/sidecar-protocol.js"; import { safeAcpxLocations } from "./acpx-sidecar-locations.js"; +import { createCursorToolEvidence, type CursorToolEvidence } from "../drivers/acpx/cursor-tool-evidence.js"; import { persistedAcpxTurnUsage, + persistedCursorUsageNotice, acpxUsageEstimateNotice, qualifiedAcpxUsageBreakdown, } from "../drivers/acpx/usage-accounting.js"; @@ -136,6 +139,7 @@ interface PendingPermission { turnId: string; responseDelivery: Promise; normalized: NormalizedAcpxPermission; + deliveredEvidence?: (outcome: string) => void; settle(response: AcpPermissionDecision): void; cleanup(): void; } @@ -285,6 +289,7 @@ async function dispatch( clientCapabilities: acpxProfileClientCapabilities(params.agent), model: params.model, permissionMode: params.permissionMode, + cursorMode: params.cursorMode, providerPolicy: params.providerPolicy, systemInstructions: params.systemInstructions, runtimeContext: params.runtimeContext, @@ -354,6 +359,7 @@ async function dispatch( if (!runId) throw new Error("attach a run before starting an ACPX turn"); if (turnId) throw new Error("ACPX sidecar already has an active turn"); const currentTurnId = boundedIdentity(request.params.turnId, "turnId"); + const activeAgent = openParams!.agent; turnId = currentTurnId; turnControls.begin(currentTurnId); let runtimeTurn: AcpxRuntimeTurn; @@ -366,6 +372,14 @@ async function dispatch( waitForInput: (input, context) => waitForExtensionInput(currentTurnId, input, context), emit: event => emit("runtime.rich_event", { ...event }, currentTurnId), }); + const evidenceFactory = openParams!.agent === "cursor" ? createCursorToolEvidence : undefined; + const toolEvidence = evidenceFactory?.({ + sessionId: activeHost.identity().backendSessionId, turnId: currentTurnId, + workingDirectory: openParams!.workingDirectory, + active: () => turnId === currentTurnId && host === activeHost, + emit: event => { validateAcpxRichEvent(event); emit("runtime.rich_event", { ...event }, currentTurnId); }, + unavailable: () => diagnostic(`${openParams!.agent}_evidence_unavailable`, "ACP tool evidence is incomplete; permission and terminal outcomes are unchanged."), + }); let usageBefore: unknown; try { usageBefore = await readSidecarHostStatusWithin(activeHost); @@ -377,13 +391,13 @@ async function dispatch( onElicitation: (providerRequest, context) => waitForInput(currentTurnId, providerRequest, context), onPermissionRequest: (providerRequest, context) => - waitForPermission(currentTurnId, providerRequest, context), + waitForPermission(currentTurnId, activeAgent, providerRequest, context, toolEvidence), }); } catch (error) { turnId = null; throw error; } - void pumpTurn(currentTurnId, runtimeTurn, activeHost, usageBefore, extensions.drain); + void pumpTurn(currentTurnId, runtimeTurn, activeHost, usageBefore, extensions.drain, toolEvidence); // Warm sessions may defer initialize until their first prompt. Publish only // the capabilities of that live initialized connection, never old disk state. await runtimeTurn.promptStarted; @@ -407,14 +421,15 @@ async function dispatch( if (request.command === "turn.cancel") { const expected = boundedIdentity(request.params.turnId, "turnId"); if (expected !== turnId) throw new Error("cannot cancel a stale ACPX turn"); - await requireHost().interruptActiveTurn( + const activeHost = requireHost(); + await activeHost.interruptActiveTurn( boundedOptionalText( request.params.reason, "Paperclip cancellation", 4_000, ), ); - return { cancelled: true }; + return { cancelled: true, sessionClosed: activeHost.isClosed() }; } if (request.command === "permission.resolve") { const requestId = boundedIdentity(request.params.requestId, "requestId"); @@ -427,7 +442,9 @@ async function dispatch( const decision = pending.normalized.resolve(resolution); if (!permissions.delete(requestId)) throw new Error("permission request lost its settlement race"); pending.cleanup(); - return await deliverAcpxResponse(pending.responseDelivery, () => pending.settle(decision)); + const receipt = await deliverAcpxResponse(pending.responseDelivery, () => pending.settle(decision)); + pending.deliveredEvidence?.(decision.outcome); + return receipt; } if (request.command === "input.resolve") { const requestId = boundedIdentity(request.params.requestId, "requestId"); @@ -589,6 +606,7 @@ async function pumpTurn( activeHost: AcpxRuntimeHost, usageBefore: unknown, drainExtensions: () => Promise, + toolEvidence?: CursorToolEvidence, ): Promise { let terminal: Record; try { @@ -599,6 +617,7 @@ async function pumpTurn( const normalizeMessage = initializedAgent === "grok" ? createGrokMessageNormalizer() : (event: AcpRuntimeEvent) => event; for await (const event of runtimeTurn.events) { + toolEvidence?.tool(event); emit( "runtime.event", sanitizeRuntimeEvent( @@ -610,9 +629,16 @@ async function pumpTurn( const result = await runtimeTurn.result; await drainExtensions(); try { + const usageAfter = await readSidecarHostStatusWithin(activeHost); + try { + const cursorNotice = persistedCursorUsageNotice(usageBefore, usageAfter, runtimeTurn.requestId, openParams?.agent ?? null, `${currentTurnId}:cursor-native-usage`); + if (cursorNotice) { validateAcpxRichEvent(cursorNotice); emit("runtime.rich_event", { ...cursorNotice }, currentTurnId); } + } catch { + // Optional diagnostics must not suppress standard usage or terminal settlement. + } const usage = persistedAcpxTurnUsage( usageBefore, - await readSidecarHostStatusWithin(activeHost), + usageAfter, runtimeTurn.requestId, openParams?.agent, ); @@ -677,13 +703,22 @@ async function waitForTool(call: RunnerToolCall): Promise { "ACPX semantic result disposition does not match its terminal operation", ); } + // Semantic result identity must survive the wire encoder unchanged. Its + // display-text Unicode repair is unsuitable for authenticated result data. + try { + if (stringifyAcpxSidecarFrame(validation.result) !== JSON.stringify(validation.result)) { + throw new Error("Semantic input changes during encoding"); + } + } catch { + throw new Error("ACPX semantic result cannot be encoded without changing its identity"); + } // The authenticated runner bridge must admit this built-in invocation // before the provider sees a result. Keep the call pending until runnerd // sends tool.resolve after the server's completion feedback accepts it. // This is the same roundtrip used by ordinary dynamic tools; emitting a // local semantic_result here would let an invalid review handoff appear // accepted before the server has checked it. - emit( + const forwarded = emit( "runtime.tool_called", { callId, @@ -692,6 +727,7 @@ async function waitForTool(call: RunnerToolCall): Promise { }, activeTurnId, ); + if (!forwarded) throw new Error("ACPX semantic tool call exceeds the sidecar frame limit"); return await new Promise((settle, reject) => { const abort = () => { const pending = tools.get(callId); @@ -702,7 +738,12 @@ async function waitForTool(call: RunnerToolCall): Promise { call.signal.addEventListener("abort", abort, { once: true }); tools.set(callId, { turnId: activeTurnId, - settle, + settle: (result) => { + // A pipe write alone does not prove receiver admission. Only this + // call's turn-bound tool.resolve success confirms runnerd accepted + // the validated body; rejection, cancellation and timeout stay null. + settle(result); + }, reject, cleanup: () => call.signal.removeEventListener("abort", abort), }); @@ -738,14 +779,19 @@ async function waitForTool(call: RunnerToolCall): Promise { async function waitForPermission( activeTurnId: string, + agent: QualifiedAcpxAgent, request: AcpPermissionRequest, context: { signal: AbortSignal; responseDelivery?: Promise }, + toolEvidence?: CursorToolEvidence, ): Promise { const { signal } = context; if (turnId !== activeTurnId || signal.aborted || permissions.size >= MAX_PENDING_INPUTS) { return { outcome: "cancel" }; } - const normalized = normalizeAcpxPermission(request, ["pi", "copilot"].includes(openParams?.agent ?? "") ? { allowAlwaysScope: "session" } : {}); + const normalized = normalizeAcpxPermission(request, { + provider: agent, workingDirectory: openParams?.workingDirectory, + ...(["pi", "copilot"].includes(agent) ? { allowAlwaysScope: "session" } : {}), + }); const responseDelivery = requireAcpxResponseDelivery(context); const requestId = stableRequestId(activeTurnId, ++requestSequence, normalized.toolCallId); return await new Promise((settle) => { @@ -756,6 +802,7 @@ async function waitForPermission( }; permissions.set(requestId, { turnId: activeTurnId, normalized, settle, responseDelivery, + deliveredEvidence: toolEvidence?.permission(request, requestId, normalized.choices.map(choice => choice.key)), cleanup: () => signal.removeEventListener("abort", abort), }); signal.addEventListener("abort", abort, { once: true }); @@ -763,6 +810,7 @@ async function waitForPermission( emit("runtime.permission_requested", { requestId, kind: normalized.kind, title: normalized.title, toolCallId: normalized.toolCallId, choices: normalized.choices, + origin: { adapter: "acpx-runtime-sidecar", provider: agent, method: "session/request_permission" }, }, activeTurnId); }); } @@ -821,6 +869,7 @@ async function waitForExtensionInput( ): Promise> { if (turnId !== activeTurnId || context.signal.aborted || inputs.size >= MAX_PENDING_INPUTS) return input.cancel(); const responseDelivery = requireAcpxResponseDelivery(context); + const toolCallId = cursorPlanToolIdentity(openParams?.agent ?? initializedAgent, input); const requestId = stableRequestId(activeTurnId, ++requestSequence, context.requestId); return await new Promise((settle) => { const abort = () => { @@ -841,6 +890,7 @@ async function waitForExtensionInput( context.signal.addEventListener("abort", abort, { once: true }); emit("runtime.input_requested", { requestId, questionSet: input.questionSet, + ...(toolCallId === undefined ? {} : { toolCallId }), origin: { adapter: "acpx-runtime-sidecar", provider: openParams?.agent ?? initializedAgent ?? "unknown", method: input.method }, }, activeTurnId); if (context.signal.aborted) abort(); @@ -1140,6 +1190,7 @@ function parseOpenParams( ): AcpxSidecarOpenParams { const agent = requireQualifiedAgent(value.agent); const model = requiredText(value.model, "model"); + if (value.cursorMode !== undefined && agent !== "cursor") throw new Error("cursorMode is supported only for Cursor"); resolveQualifiedAcpxProfile(agent, model); if ( value.providerSessionKey !== undefined && @@ -1159,6 +1210,7 @@ function parseOpenParams( agent, model, permissionMode: requiredPermissionMode(value.permissionMode), + ...(agent === "cursor" ? { cursorMode: requiredCursorMode(value.cursorMode === undefined ? "agent" : value.cursorMode) } : {}), permissionModePinned: value.permissionModePinned === true, ...(value.providerPolicy == null ? {} : { providerPolicy: parseProviderPolicy(value.providerPolicy) }), systemInstructions: boundedText( @@ -1228,6 +1280,7 @@ function parseExpectedIdentity(value: unknown): AcpxExpectedSessionIdentity { ...(input.permissionMode === undefined ? {} : { permissionMode: requiredPermissionMode(input.permissionMode) }), + ...(input.cursorMode === undefined ? {} : { cursorMode: requiredCursorMode(input.cursorMode) }), providerLifetimeFenceCandidates: requiredFenceCandidates( input.providerLifetimeFenceCandidates, ), @@ -1252,6 +1305,11 @@ function requiredFenceCandidates( return Object.freeze([...value]) as readonly [number, number, number]; } +function requiredCursorMode(value: unknown): "agent" | "plan" | "ask" { + if (value === "agent" || value === "plan" || value === "ask") return value; + throw new Error("cursorMode must be agent, plan, or ask"); +} + function requiredPermissionMode( value: unknown, ): AcpxSidecarOpenParams["permissionMode"] { @@ -1272,18 +1330,21 @@ function emit( eventType: AcpxSidecarEvent["eventType"], payload: Record, eventTurnId: string | null = turnId, -): void { +): boolean { if (sequence >= Number.MAX_SAFE_INTEGER) { throw new Error("ACPX sidecar event sequence exhausted"); } - writeFrame({ + const accepted = writeFrame({ protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, - sequence: ++sequence, + sequence: sequence + 1, eventType, runId, turnId: eventTurnId, payload, }); + // A dropped frame never entered the stream and must not consume an identity. + if (accepted) sequence++; + return accepted; } function diagnostic(code: string, message: string): void { @@ -1307,13 +1368,16 @@ function response( }); } -function writeFrame(value: AcpxSidecarEvent | AcpxSidecarResponse): void { +function writeFrame(value: AcpxSidecarEvent | AcpxSidecarResponse): boolean { const line = stringifyAcpxSidecarFrame(value); if (Buffer.byteLength(line) > ACPX_SIDECAR_MAX_FRAME_BYTES) { process.stderr.write("[paperclip-acpx-sidecar] output_frame_too_large\n"); - return; + return false; } process.stdout.write(`${line}\n`); + // Writable.write(false) accepted the bytes into its queue; it signals + // backpressure, not a rejected frame. A synchronous write error still throws. + return true; } function requireHost( @@ -1385,6 +1449,9 @@ function stableRequestId( } function stableProviderIdentity(value: string, kind: string): string { + // Cursor alone uses the richer permission/evidence identity policy. Other + // providers and message identities retain their existing sidecar mapping. + if (kind === "tool" && openParams?.agent === "cursor") return cursorToolIdentity(value); if (Buffer.byteLength(value) <= 240 && !/[\u0000-\u001f\u007f]/.test(value)) { return value; } diff --git a/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts b/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts index 9f508bb2898..1887b912ce6 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts @@ -130,6 +130,8 @@ const GENERIC_ACPX_OUTPUT_CODES = new Set([ ]); const STABLE_ACPX_SIDECAR_CODES = new Set([ + "COPILOT_POLICY_VIOLATION", + "COPILOT_DETACHED_WORK_UNSUPPORTED", "ACP_MODEL_UNSUPPORTED", "ACP_SESSION_INIT_FAILED", "AGENT_DISCONNECTED", diff --git a/packages/paperclip-runner/src/cli/acpx-sidecar-locations.ts b/packages/paperclip-runner/src/cli/acpx-sidecar-locations.ts index c0ee70d297d..784654c57e2 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-locations.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-locations.ts @@ -1,139 +1,6 @@ -import { lstatSync, realpathSync } from "node:fs"; -import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; - -import { classifyGeneratedAcpxToolOperation } from "../drivers/acpx/generated-sidecar-contract.js"; - -export const ACPX_WORKSPACE_RELATIVE_DISPLAY_BOUNDARY = - "paperclip.workspace_relative_display.v2"; -export const ACPX_WORKSPACE_ENTRY_ATTESTATION = "paperclip.workspace_entry.v1"; -export const ACPX_WORKSPACE_CREATE_TARGET_ATTESTATION = - "paperclip.workspace_create_target.v1"; -const RFC_URI_SCHEME_PREFIX = /^[A-Za-z][A-Za-z0-9+.-]*:/u; -const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u; - -function record(value: unknown): Record { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function permitsCreateTarget(toolKind: unknown, toolTitle: unknown): boolean { - return classifyGeneratedAcpxToolOperation(toolKind, toolTitle) === "edit"; -} - -/** - * Converts provider paths to workspace-relative display targets using the - * sidecar host's path semantics. URI-scheme and Windows drive-shaped values - * are ambiguous on POSIX, so they require a real, in-workspace filesystem - * entry before the sidecar may attest them as filename data. Leading - * backslashes likewise require native-host attestation because they are rooted - * syntax on Windows but valid filename data on POSIX. Consumers must - * treat the result as display data, never as file-access authorization. - */ -export function safeAcpxLocations( - locations: readonly unknown[] | null | undefined, - workingDirectory: string | null | undefined, - toolKind?: unknown, - toolTitle?: unknown, -): Array> { - if (!workingDirectory) return []; - const cwd = resolve(workingDirectory); - let canonicalCwd: string | null | undefined; - return (locations ?? []).slice(0, 2_000).flatMap((location) => { - const candidate = record(location); - const rawPath = typeof candidate.path === "string" ? candidate.path : ""; - if (!rawPath || rawPath.includes("\0")) return []; - const absolute = isAbsolute(rawPath) - ? resolve(rawPath) - : resolve(cwd, rawPath); - const local = relative(cwd, absolute); - if (!local || isAbsolute(local)) return []; - const portable = sep === "\\" ? local.replaceAll("\\", "/") : local; - if ( - portable.startsWith("/") || - portable.split("/").some((segment) => segment === "..") - ) { - return []; - } - // Classify the value we actually emit as well as the provider's spelling. - // Dot-relative and absolute paths can normalize to an ambiguous display - // target even though their raw forms did not begin with an ambiguous shape. - const requiresEntryAttestation = - rawPath.startsWith("\\") || - portable.startsWith("\\") || - WINDOWS_DRIVE_PREFIX.test(rawPath) || - RFC_URI_SCHEME_PREFIX.test(rawPath) || - WINDOWS_DRIVE_PREFIX.test(portable) || - RFC_URI_SCHEME_PREFIX.test(portable); - let pathAttestation: string | undefined; - if (requiresEntryAttestation) { - if (canonicalCwd === undefined) { - try { - canonicalCwd = realpathSync(cwd); - } catch { - canonicalCwd = null; - } - } - if (canonicalCwd === null) return []; - - let entryExists = true; - try { - lstatSync(absolute); - } catch (error) { - entryExists = false; - if ( - !permitsCreateTarget(toolKind, toolTitle) || - !( - error !== null && - typeof error === "object" && - "code" in error && - error.code === "ENOENT" - ) - ) { - return []; - } - } - - if (entryExists) { - try { - const canonicalEntry = realpathSync(absolute); - const entryRelative = relative(canonicalCwd, canonicalEntry); - if ( - !entryRelative || - isAbsolute(entryRelative) || - entryRelative.split(sep).some((segment) => segment === "..") - ) { - return []; - } - pathAttestation = ACPX_WORKSPACE_ENTRY_ATTESTATION; - } catch { - // An existing but unresolved entry includes dangling links. It must - // not be downgraded to a create target by an ENOENT from realpath. - return []; - } - } else { - try { - const canonicalParent = realpathSync(dirname(absolute)); - const parentRelative = relative(canonicalCwd, canonicalParent); - if ( - isAbsolute(parentRelative) || - parentRelative.split(sep).some((segment) => segment === "..") - ) { - return []; - } - pathAttestation = ACPX_WORKSPACE_CREATE_TARGET_ATTESTATION; - } catch { - return []; - } - } - } - return [ - { - path: [...portable].slice(0, 4_000).join(""), - line: candidate.line ?? null, - pathBoundary: ACPX_WORKSPACE_RELATIVE_DISPLAY_BOUNDARY, - ...(pathAttestation ? { pathAttestation } : {}), - }, - ]; - }); -} +export { + ACPX_WORKSPACE_RELATIVE_DISPLAY_BOUNDARY, + ACPX_WORKSPACE_ENTRY_ATTESTATION, + ACPX_WORKSPACE_CREATE_TARGET_ATTESTATION, + safeAcpxLocations, +} from "../drivers/acpx/safe-locations.js"; diff --git a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts index 7082be9ad58..774b243f922 100644 --- a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts +++ b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest"; import { createCapabilityFixtureState } from "../mock-core/capability-control-plane-types.js"; import { parseNativeRuntimeContext } from "../contracts/runtime-context.js"; +import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js"; import type { CapabilityLiveSessionSnapshot } from "../live/live-session.js"; import { evalSessionUsage, @@ -78,11 +79,50 @@ describe("eval-session request contract", () => { it("accepts only known diagnostic flags and rejects ambiguous repeated arguments", () => { const args = ["--request", "/tmp/request.json", "--output", "/tmp/result.json"]; - expect(parseEvalSessionCliArgs([...args, "--candidate-profile", "pi"])).toMatchObject({ candidateProfile: "pi" }); + const expected = resolveQualifiedAcpxProfile("pi", "openrouter/deepseek/deepseek-v4-flash-0731"); + expect(parseEvalSessionCliArgs([...args, "--candidate-profile", "pi", "--expected-acpx-profile", JSON.stringify(expected)])) + .toMatchObject({ candidateProfile: "pi", expectedAcpxProfile: expected }); + expect(() => parseEvalSessionCliArgs([...args, "--candidate-profile", "pi"])).toThrow("require --expected-acpx-profile"); expect(() => parseEvalSessionCliArgs([...args, "--candidate-profile", "codex"])).toThrow("must be pi, cursor, or copilot"); expect(() => parseEvalSessionCliArgs([...args, "--candidate-profile"])).toThrow("missing"); expect(() => parseEvalSessionCliArgs([...args, "--request", "/tmp/other.json"])).toThrow("duplicate"); expect(() => parseEvalSessionRequest(request(), { candidateProfile: "pi" })).toThrow("must match"); + for (const invalid of ["null", "[]", "true", "{", JSON.stringify("text")]) { + expect(() => parseEvalSessionCliArgs([...args, "--expected-acpx-profile", invalid])).toThrow("JSON object"); + } + expect(() => parseEvalSessionCliArgs([...args, "--expected-acpx-profile", " ".repeat(4097)])).toThrow("4096-byte bound"); + }); + + it.each(["cursor"] as const)("rejects stale or incomplete %s identities before runtime construction", async (agent) => { + const workspace = await mkdtemp(join(tmpdir(), "paperclip-eval-profile-")); + const model = "explicit-provider-model"; + const expected = resolveQualifiedAcpxProfile(agent, model); + const requestPath = join(workspace, "request.json"); + const args = ["--request", requestPath, "--output", join(workspace, "output.json"), "--candidate-profile", agent]; + const serviceFactory = vi.fn(() => { throw new Error("provider must not start"); }); + try { + await writeFile(requestPath, JSON.stringify(request({ provider: "acpx", acpxAgent: agent, model, + runnerd: { path: join(workspace, "missing-runnerd"), sha256: "a".repeat(64) }, + session: { workingDirectory: workspace }, + }))); + const { commandDigest: _removed, ...incomplete } = expected; + for (const stale of [ + {}, incomplete, { ...expected, unexpected: true }, + { ...expected, agentProfileVersion: expected.agentProfileVersion - 1 }, + { ...expected, commandDigest: `sha256:${"0".repeat(64)}` }, + { ...expected, reportedModelId: "another-model" }, + { ...expected, agentServerVersion: "previous-binary" }, + ]) { + await expect(runEvalSessionCli([...args, "--expected-acpx-profile", JSON.stringify(stale)], { serviceFactory })) + .rejects.toThrow("does not match the built runner profile"); + } + await expect(runEvalSessionCli(args, { serviceFactory })).rejects.toThrow("require --expected-acpx-profile"); + // A matching identity reaches the independent runner-binary digest gate. + await expect(runEvalSessionCli([...args, "--expected-acpx-profile", JSON.stringify(expected)], { serviceFactory })) + .rejects.toThrow("ENOENT"); + expect(serviceFactory).not.toHaveBeenCalled(); + expect(await readdir(workspace)).toEqual(["request.json"]); + } finally { await rm(workspace, { recursive: true, force: true }); } }); it("materializes a production-v3 runtime context for direct live providers", async () => { @@ -446,7 +486,7 @@ describe("eval-session budget settlement", () => { const requestPath = join(workspace, "request.json"); const outputPath = join(workspace, "output.json"); await writeFile(requestPath, JSON.stringify(input)); - const exitCode = await runEvalSessionCli(["--request", requestPath, "--output", outputPath, ...(agent === "grok" ? [] : ["--candidate-profile", "copilot"])], { + const exitCode = await runEvalSessionCli(["--request", requestPath, "--output", outputPath, ...(agent === "grok" ? [] : ["--candidate-profile", "copilot", "--expected-acpx-profile", JSON.stringify(resolveQualifiedAcpxProfile("copilot", model))])], { serviceFactory: () => ({ create: async () => ({ sendMessage, completeAttempt, shutdown, snapshot: () => snapshot }) }) as never, }); const artifact = JSON.parse(await readFile(outputPath, "utf8")); diff --git a/packages/paperclip-runner/src/cli/eval-session.ts b/packages/paperclip-runner/src/cli/eval-session.ts index 19153159445..a7b7597f6c5 100644 --- a/packages/paperclip-runner/src/cli/eval-session.ts +++ b/packages/paperclip-runner/src/cli/eval-session.ts @@ -40,6 +40,7 @@ interface EvalSessionCliOptions { requestPath: string; outputPath: string; candidateProfile?: EvalCandidateProfile; + expectedAcpxProfile?: Record; } function argument(args: string[], name: string): string { @@ -50,7 +51,7 @@ function argument(args: string[], name: string): string { } export function parseEvalSessionCliArgs(args: string[]): EvalSessionCliOptions { - const allowed = new Set(["--request", "--output", "--candidate-profile"]); + const allowed = new Set(["--request", "--output", "--candidate-profile", "--expected-acpx-profile"]); const seen = new Set(); for (let index = 0; index < args.length; index += 2) { if (!allowed.has(args[index] ?? "")) { @@ -65,8 +66,28 @@ export function parseEvalSessionCliArgs(args: string[]): EvalSessionCliOptions { if (candidateProfile !== undefined && candidateProfile !== "pi" && candidateProfile !== "cursor" && candidateProfile !== "copilot") { throw new Error("--candidate-profile must be pi, cursor, or copilot"); } + const expectedIndex = args.indexOf("--expected-acpx-profile"); + const expectedText = expectedIndex < 0 ? undefined : args[expectedIndex + 1]; + if (candidateProfile !== undefined && expectedText === undefined) { + throw new Error("Candidate evals require --expected-acpx-profile before provider execution"); + } + let expectedAcpxProfile: Record | undefined; + if (expectedText !== undefined) { + if (Buffer.byteLength(expectedText, "utf8") > 4_096) { + throw new Error("--expected-acpx-profile exceeds its 4096-byte bound"); + } + let parsed: unknown; + try { parsed = JSON.parse(expectedText); } catch { + throw new Error("--expected-acpx-profile must be a JSON object"); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("--expected-acpx-profile must be a JSON object"); + } + expectedAcpxProfile = parsed as Record; + } return { ...(candidateProfile === undefined ? {} : { candidateProfile }), + ...(expectedAcpxProfile === undefined ? {} : { expectedAcpxProfile }), requestPath: argument(args, "--request"), outputPath: argument(args, "--output"), }; @@ -324,6 +345,20 @@ export async function runEvalSessionCli( JSON.parse(await readFile(cli.requestPath, "utf8")), { candidateProfile: cli.candidateProfile }, ); + // Check the built CLI's profile before constructing any runtime context, + // transport or service. Scoring after a paid turn is too late for admission. + if (cli.expectedAcpxProfile !== undefined) { + if (request.provider !== "acpx") { + throw new Error("--expected-acpx-profile requires an ACPX request"); + } + const actualProfile = resolveQualifiedAcpxProfile(request.acpxAgent ?? "codex", request.model); + const entries = Object.entries(actualProfile); + if (Object.keys(cli.expectedAcpxProfile).length !== entries.length || entries.some( + ([key, value]) => !Object.hasOwn(cli.expectedAcpxProfile!, key) || cli.expectedAcpxProfile![key] !== value, + )) { + throw new Error("--expected-acpx-profile does not match the built runner profile"); + } + } const runnerdPath = resolve(request.runnerd.path); const actualDigest = await sha256(runnerdPath); if (actualDigest !== request.runnerd.sha256.replace(/^sha256:/, "")) { diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts index b6bbe6103bf..9be6d782feb 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -454,6 +454,8 @@ export interface AcpxSessionIdentity { effectiveModel: string; /** Missing on legacy snapshots; those used the historical approve-reads behavior. */ permissionMode?: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; + /** Effective native mode; required on current Cursor session identities. */ + cursorMode?: "agent" | "plan" | "ask"; providerLifetimeFenceCandidates: readonly [number, number, number]; } diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index 9d089fde854..bb5fc12b8c3 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -84,13 +84,14 @@ export type NativeAcpxAgent = "pi" | "claude" | "codex" | "grok" | "cursor" | "c export type NativeCodexApprovalPolicy = "never" | "on-request" | "untrusted"; export type NativeOpenCodePermissionMode = "allow" | "ask" | "deny"; export type NativeAcpxPermissionMode = "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; +export type NativeCursorMode = "agent" | "plan" | "ask"; export interface NativeAcpxProfileSnapshot { driverKind: "acpx_runtime"; protocolVersion: 1; acpxVersion: "0.13.1"; agent: NativeAcpxAgent; - agentProfileVersion: 1 | 2 | 3 | 4 | 5; + agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; agentServerPackage: string; agentServerVersion: string; agentRuntimePackage: string | null; @@ -123,6 +124,7 @@ export type NativeProviderConfig = agent: NativeAcpxAgent; model: string; permissionMode?: NativeAcpxPermissionMode; + cursorMode?: NativeCursorMode; /** Present only in persisted v1-v3 inputs. */ permissionPolicy?: "interactive"; profile: NativeAcpxProfileSnapshot; @@ -137,6 +139,7 @@ export type NativeProviderConfigV4 = agent: NativeAcpxAgent; model: string; permissionMode: NativeAcpxPermissionMode; + cursorMode?: NativeCursorMode; profile: NativeAcpxProfileSnapshot; }; @@ -450,7 +453,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput : provider.kind === "aws_agentcore" ? ["kind", "model", "agentCoreProfile", "maxEstimatedSessionCostUsd", "invocationLimits"] : provider.kind === "acpx" - ? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile"] + ? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile", ...(isV4 ? ["cursorMode"] : [])] : provider.kind === "codex" && isV4 ? ["kind", "model", "approvalPolicy", ...(isV5 ? ["reasoningEffort"] : [])] : provider.kind === "opencode" && isV4 @@ -570,6 +573,10 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput invocationLimits: { maxIterations, maxOutputTokens, timeoutSeconds }, }; } else if (provider.kind === "acpx") { + if (provider.cursorMode !== undefined && (provider.agent !== "cursor" + || (provider.cursorMode !== "agent" && provider.cursorMode !== "plan" && provider.cursorMode !== "ask"))) { + throw new NativeExecutionInputError("input.provider.cursorMode must be agent, plan, or ask and is supported only for Cursor"); + } if (providerModel === null) { throw new NativeExecutionInputError("input.provider.model is required for acpx"); } @@ -601,7 +608,8 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput || profile.protocolVersion !== 1 || profile.acpxVersion !== "0.13.1" || profile.agent !== provider.agent - || (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5) + || (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6 && profile.agentProfileVersion !== 7 && profile.agentProfileVersion !== 8 && profile.agentProfileVersion !== 9 && profile.agentProfileVersion !== 10 && profile.agentProfileVersion !== 11 && profile.agentProfileVersion !== 12) + || (provider.agent !== "cursor" && (profile.agentProfileVersion as number) > 5) ) { throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile"); } @@ -617,6 +625,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput ...(isV4 ? { permissionMode: provider.permissionMode as NativeAcpxPermissionMode } : { permissionPolicy: "interactive" as const }), + ...(provider.cursorMode === undefined ? {} : { cursorMode: provider.cursorMode as NativeCursorMode }), profile: { driverKind: "acpx_runtime", protocolVersion: 1, diff --git a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts index 6eac1f3c6ec..ff125d537dd 100644 --- a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts @@ -1,5 +1,7 @@ import type { AcpPermissionDecision, AcpPermissionRequest } from "acpx/runtime"; import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js"; +import { cursorToolIdentity } from "./cursor-plan-tool-identity.js"; + export type AcpxPermissionAction = "accept" | "accept_for_session" | "decline" | "cancel"; export interface NormalizedAcpxPermission { @@ -11,7 +13,7 @@ export interface NormalizedAcpxPermission { } /** Provider options describe choices, never authority to execute an operation. */ -export function normalizeAcpxPermission(request: AcpPermissionRequest, options: { allowAlwaysScope?: "session" } = {}): NormalizedAcpxPermission { +export function normalizeAcpxPermission(request: AcpPermissionRequest, options: { allowAlwaysScope?: "session"; provider?: string; workingDirectory?: string } = {}): NormalizedAcpxPermission { const raw = request.raw; if (!raw || !Array.isArray(raw.options) || raw.options.length > 32) { throw new Error("ACP permission request has invalid options"); @@ -29,6 +31,11 @@ export function normalizeAcpxPermission(request: AcpPermissionRequest, options: ids.add(option.optionId); byKind.set(option.kind, { optionId: option.optionId, name: option.name }); } + const call = raw.toolCall; + if (!call || typeof call.toolCallId !== "string" || !call.toolCallId || call.toolCallId.length > 240 + || (options.provider === "cursor" && !call.toolCallId.trim())) { + throw new Error("ACP permission request omitted its tool identity"); + } const bindings = new Map(); const choices: NormalizedAcpxPermission["choices"] = []; for (const [key, kind, label] of [ @@ -42,15 +49,13 @@ export function normalizeAcpxPermission(request: AcpPermissionRequest, options: // A permanent rejection is not silently substituted for a one-time denial. bindings.set("cancel", "cancel"); choices.push({ key: "cancel", label: "Cancel" }); - const call = raw.toolCall; - if (!call || typeof call.toolCallId !== "string" || !call.toolCallId || call.toolCallId.length > 240) { - throw new Error("ACP permission request omitted its tool identity"); - } return { title: typeof call.title === "string" && call.title.trim() ? call.title.slice(0, 4_000) : "Approve provider operation", kind: request.inferredKind ?? "other", - toolCallId: call.toolCallId, + // Display/durable identity must match the tool-event boundary. Leave the + // original request intact for ACPX's exact native response correlation. + toolCallId: options.provider === "cursor" ? cursorToolIdentity(call.toolCallId) : call.toolCallId, choices, resolve(resolution) { const outcome = bindings.get(resolution.action as AcpxPermissionAction); diff --git a/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.ts index 7250cc1a434..e36ef686369 100644 --- a/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/acp-question-adapter.ts @@ -148,6 +148,7 @@ function normalizeField( question: { ...base, answerMode: "text", + ...(property.default !== undefined ? { initialText: initialText(property.default, type) } : {}), textValidation: { inputType: "text", ...(minLength !== undefined ? { minLength } : {}), @@ -168,6 +169,7 @@ function normalizeField( question: { ...base, answerMode: "text", + ...(property.default !== undefined ? { initialText: initialText(property.default, type) } : {}), textValidation: { inputType: type, ...(minimum !== undefined ? { minimum } : {}), @@ -376,3 +378,10 @@ function finiteNonNegativeInteger(value: unknown): number | undefined { ? Number(value) : undefined; } + +/** Defaults are editable presentation, not acceptance or native response content. */ +function initialText(value: unknown, type: string): string { + if (type === "string" && typeof value === "string") return value; + if ((type === "number" || type === "integer") && typeof value === "number" && Number.isFinite(value)) return String(value); + throw new Error(`ACP ${type} text field default has an incompatible type`); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.test.ts b/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.test.ts new file mode 100644 index 00000000000..27437e8fde9 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.test.ts @@ -0,0 +1,56 @@ +import { mkdtempSync, mkdirSync, renameSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-context.js"; +import { bindAcpxAgentFiles } from "./agent-files-binding.js"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +function fixture() { + const parent = mkdtempSync(join(tmpdir(), "acpx-agent-files-")); + roots.push(parent); + const root = join(parent, "registered-run-copy"); + const protectedRoot = join(parent, "provider-state"); + mkdirSync(root); mkdirSync(protectedRoot); + const context = { instructions: { workingCopy: { kind: "agent_files", rootPath: root, entryPath: "AGENTS.md" } } } as NativeRuntimeContextSnapshot; + return { parent, root, protectedRoot, context }; +} + +describe("registered ACP agent files", () => { + it("does not grant absent or legacy instruction copies", () => { + expect(bindAcpxAgentFiles(null, [])).toBeNull(); + const { context } = fixture(); + delete context.instructions.workingCopy!.kind; + expect(bindAcpxAgentFiles(context, [])).toBeNull(); + }); + it("binds only the registered copy and detects replaced roots", () => { + const { root, context, protectedRoot } = fixture(); + const binding = bindAcpxAgentFiles(context, [protectedRoot])!; + expect(binding.root.endsWith("/registered-run-copy")).toBe(true); + expect(() => binding.assertHeld()).not.toThrow(); + renameSync(root, `${root}-old`); mkdirSync(root); + expect(() => binding.assertHeld()).toThrow("changed"); + }); + it("rejects symlink roots and symlink replacement", () => { + const { root, context, protectedRoot } = fixture(); + const binding = bindAcpxAgentFiles(context, [])!; + renameSync(root, `${root}-old`); symlinkSync(protectedRoot, root); + expect(() => binding.assertHeld()).toThrow("changed"); + expect(() => bindAcpxAgentFiles(context, [])).toThrow("real directory"); + }); + it("rejects both directions of protected-root overlap", () => { + const { parent, root, context } = fixture(); + const child = join(root, "nested-runtime"); mkdirSync(child); + for (const protectedRoot of [parent, root, child]) { + expect(() => bindAcpxAgentFiles(context, [protectedRoot])).toThrow("overlaps"); + } + }); + it("rejects relative, non-normalized and filesystem-root grants", () => { + const { context } = fixture(); + for (const rootPath of ["relative", "/tmp/../tmp", "/", "/tmp\0invalid"]) { + context.instructions.workingCopy!.rootPath = rootPath; + expect(() => bindAcpxAgentFiles(context, [])).toThrow(); + } + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.ts b/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.ts new file mode 100644 index 00000000000..4208e7baa04 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.ts @@ -0,0 +1,48 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { isAbsolute, relative, resolve } from "node:path"; +import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-context.js"; + +export interface AcpxAgentFilesBinding { + readonly root: string; + assertHeld(): void; +} + +/** The authenticated runtime context, never an ambient environment value, grants this run copy. */ +export function bindAcpxAgentFiles( + context: NativeRuntimeContextSnapshot | null | undefined, + protectedRoots: readonly string[], +): AcpxAgentFilesBinding | null { + const copy = context?.instructions.workingCopy; + if (copy?.kind !== "agent_files") return null; + const declared = copy.rootPath; + if (!isAbsolute(declared) || declared.includes("\0") || resolve(declared) !== declared) { + throw new Error("ACP agent directory must be an absolute normalized registered path"); + } + const metadata = lstatSync(declared); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error("ACP agent directory must be a real directory"); + } + const root = realpathSync(declared); + if (resolve(root, "..") === root) throw new Error("ACP agent directory cannot be a filesystem root"); + for (const protectedRoot of protectedRoots) { + // These roots belong to the verified runtime and sandbox, not user input. + const canonical = realpathSync(protectedRoot); + if (contains(root, canonical) || contains(canonical, root)) { + throw new Error("ACP agent directory overlaps protected runtime state"); + } + } + const assertHeld = () => { + const current = lstatSync(declared); + if (!current.isDirectory() || current.isSymbolicLink() + || current.dev !== metadata.dev || current.ino !== metadata.ino + || realpathSync(declared) !== root) { + throw new Error("ACP registered agent directory changed during provider lifetime"); + } + }; + return Object.freeze({ root, assertHeld }); +} + +function contains(root: string, path: string): boolean { + const rel = relative(root, path); + return rel === "" || (!rel.startsWith("../") && rel !== ".." && !isAbsolute(rel)); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts index fdc73bddcff..a5739b23c65 100644 --- a/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts @@ -5,7 +5,7 @@ export interface AcpxCapabilityProfile { readonly qualification: "qualified" | "pending"; readonly models: "explicit-provider-verified" | "exact-qualified"; readonly permissions: "runner-policy" | "interactive"; - readonly questions: "form" | "cursor-extension" | "not-exposed"; + readonly questions: "form" | "cursor-extension" | "semantic-only" | "not-exposed"; readonly plans: "native" | "cursor-decision" | "semantic-only"; readonly tools: "authenticated-mcp" | "owned-extension"; readonly recovery: "session-load" | "unverified"; @@ -43,11 +43,11 @@ export const ACPX_CAPABILITY_PROFILES: Readonly; + deliveredEvidence?: () => void; prepareResolution(resolution: HarnessRuntimeRequestResolution): () => void; cancel(): void; cleanup(): void; @@ -140,7 +143,9 @@ export interface CodexAcpxDriverOptions { runtimeDirectory: string; model: string; permissionMode?: NativeAcpxPermissionMode; + cursorMode?: "agent" | "plan" | "ask"; providerPolicy?: { readOnly: boolean }; + runtimeContext?: OpenAcpxRuntimeHostOptions["runtimeContext"]; systemInstructions?: string; environment?: NodeJS.ProcessEnv; managedCodexCredentialSourcePath?: string; @@ -452,7 +457,9 @@ export class CodexAcpxDriver implements HarnessDriver { clientCapabilities: acpxProfileClientCapabilities(this.#options.agent ?? "codex"), model: this.#options.model, permissionMode: this.#options.permissionMode ?? "approve-all", + cursorMode: this.#options.cursorMode, providerPolicy: this.#options.providerPolicy, + runtimeContext: this.#options.runtimeContext, systemInstructions: this.#options.systemInstructions, environment: this.#options.environment, managedCodexCredentialSourcePath: @@ -894,6 +901,15 @@ class CodexAcpxSession implements HarnessSession { } }, }); + const evidenceFactory = this.#agent === "cursor" ? createCursorToolEvidence : undefined; + const toolEvidence = evidenceFactory?.({ + sessionId: this.#host.identity().backendSessionId, turnId, workingDirectory: this.#input.workingDirectory, + active: () => this.#activeTurnId === turnId && !this.#closingStarted, + emit: event => { + validateAcpxRichEvent(event); + if (!this.#emit(event.eventType, event.payload, { turnId, itemId: event.itemId })) throw new Error("ACP tool activity could not be retained"); + }, + }); let turn: AcpxRuntimeTurn; const usageBefore = await readUsageStatus(this.#host); try { @@ -905,7 +921,7 @@ class CodexAcpxSession implements HarnessSession { onExtensionRequest: extensions.onExtensionRequest, onExtensionNotification: extensions.onExtensionNotification, onPermissionRequest: (request, context) => - this.#handlePermission(turnId, request, context), + this.#handlePermission(turnId, request, context, toolEvidence), onElicitation: (request, context) => this.#handleElicitation(turnId, request, context), }); @@ -918,7 +934,7 @@ class CodexAcpxSession implements HarnessSession { ); throw error; } - const pump = this.#pumpTurn(turnId, turn, extensions.drain, usageBefore); + const pump = this.#pumpTurn(turnId, turn, extensions.drain, usageBefore, toolEvidence); this.#activePump = pump; void pump .finally(() => { @@ -1012,6 +1028,7 @@ class CodexAcpxSession implements HarnessSession { dispatched = true; deliver(); await pending.responseDelivery; + pending.deliveredEvidence?.(); this.#emit( "runtime_request.resolved", harnessRuntimeRequestOutcome(pending.request, { @@ -1257,6 +1274,7 @@ class CodexAcpxSession implements HarnessSession { requestedModel: identity.requestedModel, effectiveModel: identity.effectiveModel, permissionMode: identity.permissionMode, + ...(identity.cursorMode === undefined ? {} : { cursorMode: identity.cursorMode }), providerLifetimeFenceCandidates: identity.providerLifetimeFenceCandidates, }, @@ -1421,7 +1439,7 @@ class CodexAcpxSession implements HarnessSession { .catch(() => undefined); } - async #pumpTurn(turnId: string, turn: AcpxRuntimeTurn, drainExtensions: () => Promise, usageBefore: unknown): Promise { + async #pumpTurn(turnId: string, turn: AcpxRuntimeTurn, drainExtensions: () => Promise, usageBefore: unknown, toolEvidence?: CursorToolEvidence): Promise { try { let index = 0; const normalizeToolEvent = @@ -1429,11 +1447,20 @@ class CodexAcpxSession implements HarnessSession { const normalizeMessage = this.#agent === "grok" ? createGrokMessageNormalizer() : (event: AcpRuntimeEvent) => event; for await (const event of turn.events) { - this.#mapRuntimeEvent(normalizeMessage(normalizeToolEvent(event)), turnId, ++index); + toolEvidence?.tool(event); + const activity = this.#agent === "cursor" && event.type === "tool_call" && typeof event.toolCallId === "string" + ? { ...event, toolCallId: cursorToolExecutionId(event.toolCallId) } : event; + this.#mapRuntimeEvent(normalizeMessage(normalizeToolEvent(activity)), turnId, ++index); } const result = await turn.result; await drainExtensions(); - const receipt = persistedAcpxTurnUsage(usageBefore, await readUsageStatus(this.#host), turn.requestId, this.#agent); + const usageAfter = await readUsageStatus(this.#host); + // Diagnostic projection failures cannot replace the provider's terminal result. + try { + const notice = persistedCursorUsageNotice(usageBefore, usageAfter, turn.requestId, this.#agent, `${turnId}:cursor-native-usage`); + if (notice) { validateAcpxRichEvent(notice); this.#emit(notice.eventType, notice.payload, { turnId, itemId: notice.itemId }); } + } catch { /* Partial native diagnostics are optional, never settlement authority. */ } + const receipt = persistedAcpxTurnUsage(usageBefore, usageAfter, turn.requestId, this.#agent); if (receipt) { this.#mapRuntimeEvent(receipt as unknown as AcpRuntimeEvent, turnId, ++index); const estimate = acpxUsageEstimateNotice(receipt, `${turnId}:usage-estimate`); @@ -1671,8 +1698,10 @@ class CodexAcpxSession implements HarnessSession { || this.#pendingRuntimeRequests.size >= MAX_PENDING_RUNTIME_REQUESTS) return input.cancel(); const responseDelivery = requireAcpxResponseDelivery(context); const requestId = stableId("acpx-request", `${turnId}:${++this.#runtimeRequestSequence}:${typeof context.requestId}:${context.requestId}`); + const toolCallId = cursorPlanToolIdentity(this.#agent, input); + const itemId = toolCallId === undefined ? requestId : cursorToolExecutionId(toolCallId); const request: HarnessRuntimeRequest = { - requestId, requestKind: "elicitation", method: input.method, turnId, itemId: requestId, + requestId, requestKind: "elicitation", method: input.method, turnId, itemId, status: "pending", prompt: boundedText(input.questionSet.title ?? "Provider needs input", 1_000), details: input.details ?? {}, input: structuredClone(input.questionSet), origin: { adapter: "acpx-runtime", provider: this.#agent, method: input.method }, @@ -1684,7 +1713,7 @@ class CodexAcpxSession implements HarnessSession { pending.cleanup(); this.#emit("runtime_request.cancelled", harnessRuntimeRequestOutcome(request, { action: "cancel", reason: "provider request aborted", - }), { turnId, itemId: requestId }); + }), { turnId, itemId }); settle(input.cancel()); }; this.#pendingRuntimeRequests.set(requestId, { @@ -1699,7 +1728,7 @@ class CodexAcpxSession implements HarnessSession { }); context.signal.addEventListener("abort", cancel, { once: true }); if (!this.#emit("runtime_request.created", { request: runtimeInputProtocolPayload(request) }, - { turnId, itemId: requestId }) || context.signal.aborted) cancel(); + { turnId, itemId }) || context.signal.aborted) cancel(); }); } @@ -1707,6 +1736,7 @@ class CodexAcpxSession implements HarnessSession { turnId: string, request: AcpPermissionRequest, context: { signal: AbortSignal; responseDelivery?: Promise }, + toolEvidence?: CursorToolEvidence, ): Promise { const { signal } = context; if (this.#closed || this.#activeTurnId !== turnId || signal.aborted @@ -1714,7 +1744,10 @@ class CodexAcpxSession implements HarnessSession { return { outcome: "cancel" }; } const responseDelivery = requireAcpxResponseDelivery(context); - const normalized = normalizeAcpxPermission(request, ["pi", "copilot"].includes(this.#agent) ? { allowAlwaysScope: "session" } : {}); + const normalized = normalizeAcpxPermission(request, { + provider: this.#agent, workingDirectory: this.#input.workingDirectory, + ...(["pi", "copilot"].includes(this.#agent) ? { allowAlwaysScope: "session" } : {}), + }); const requestId = stableId("acpx-permission", `${turnId}:${++this.#runtimeRequestSequence}:${normalized.toolCallId}`); const runtimeRequest: HarnessRuntimeRequest = { requestId, requestKind: "permission_approval", method: "session/request_permission", @@ -1722,6 +1755,8 @@ class CodexAcpxSession implements HarnessSession { details: { choices: normalized.choices, toolCallId: normalized.toolCallId, kind: normalized.kind }, origin: { adapter: "acpx-runtime", provider: this.#agent, method: "session/request_permission" }, }; + const deliveredEvidence = toolEvidence?.permission(request, requestId, normalized.choices.map(choice => choice.key)); + let deliveredOutcome: AcpPermissionDecision["outcome"] | undefined; return await new Promise((settle) => { const cancel = () => { const pending = this.#pendingRuntimeRequests.get(requestId); @@ -1734,8 +1769,10 @@ class CodexAcpxSession implements HarnessSession { }; this.#pendingRuntimeRequests.set(requestId, { request: runtimeRequest, responseDelivery, + deliveredEvidence: () => { if (deliveredOutcome) deliveredEvidence?.(deliveredOutcome); }, prepareResolution: (resolution) => { const response = normalized.resolve(resolution); + deliveredOutcome = response.outcome; return () => settle(response); }, cancel: () => settle({ outcome: "cancel" }), @@ -2083,6 +2120,7 @@ function validateRecoverySnapshot(snapshot: PersistedHarnessSession): void { !["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes( identity.permissionMode, )) || + (identity.cursorMode !== undefined && !["agent", "plan", "ask"].includes(identity.cursorMode)) || !validProviderLifetimeFenceCandidates( identity.providerLifetimeFenceCandidates, ) 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 f79a3e291e4..772161f82bb 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 @@ -13,6 +13,7 @@ import { describe, expect, it, vi } from "vitest"; import type { VerifiedAcpxCommandLease } from "./installation-integrity.js"; import { openCodexAcpxRuntime } from "./codex-runtime-adapter.js"; +import { cursorInstructionBinding } from "./cursor-instructions.js"; import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js"; import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; import type { AcpxRuntimePortOpenOptions } from "./runtime-host.js"; @@ -52,6 +53,106 @@ describe("Codex ACPX runtime adapter", () => { 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" }); }); + it("cancels an outstanding native permission callback on the exact active turn", async () => { + const pending = pendingExtensionTurn("turn-1"); + const runtime = fakeRuntime(); vi.mocked(runtime.startTurn).mockReturnValue(pending.turn); + let created!: AcpRuntimeOptions; + const options = openOptions(fakeCommand()); + const port = await openCodexAcpxRuntime(options, { + createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; }, + }); + let callbackSignal!: AbortSignal; + const handler = vi.fn((_request, context) => new Promise<{ outcome: "cancel" }>(resolve => { + callbackSignal = context.signal; + context.signal.addEventListener("abort", () => resolve({ outcome: "cancel" }), { once: true }); + })); + 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() }); + expect(callbackSignal.aborted).toBe(false); + const oldMode = options.permissionMode; + options.permissionMode = "approve-all"; + const expiredContext = new AbortController(); expiredContext.abort(); + await expect(created.onPermissionRequest!({ sessionId: "backend-1", raw: {}, inferredKind: "edit" } as never, + { signal: expiredContext.signal })).resolves.toEqual({ outcome: "cancel" }); + options.permissionMode = oldMode; + await turn.cancel(); + expect(callbackSignal.aborted).toBe(true); + await expect(permission).resolves.toEqual({ outcome: "cancel" }); + await expect(created.onPermissionRequest!({ sessionId: "backend-1", raw: {}, inferredKind: "edit" } as never, + { signal: new AbortController().signal })).resolves.toEqual({ outcome: "cancel" }); + expect(handler).toHaveBeenCalledOnce(); + pending.settle(); await turn.result; + // The active-turn pointer has been cleared. Even full-auto policy cannot + // authorize a late callback from the retired turn. + options.permissionMode = "approve-all"; + await expect(created.onPermissionRequest!({ sessionId: "backend-1", raw: {}, inferredKind: "edit" } as never, + { signal: new AbortController().signal })).resolves.toEqual({ outcome: "cancel" }); + await port.close({ reason: "test complete" }); + }); + + it("fails Cursor adapter admission when the provider never acknowledges instructions", async () => { + const runtime = fakeRuntime(); + const options = openOptions(fakeCommand()); + options.profile = { ...options.profile, agent: "cursor" }; + await expect(openCodexAcpxRuntime(options, { + createRegistry: () => registry(), createStore: () => store(), createRuntime: () => runtime, + })).rejects.toThrow("Cursor instruction admission failed"); + expect(runtime.startTurn).not.toHaveBeenCalled(); + expect(runtime.close).toHaveBeenCalled(); + }); + it.each(["valid", "missing", "unsupported"])("cold Cursor adapter admission requires %s load/config and renews only on success", async mode => { + const runtime = fakeRuntime(); + const options = openOptions(fakeCommand()); options.profile = { ...options.profile, agent: "cursor" }; + options.refreshConsumedCommand = vi.fn(async () => undefined); + let created!: AcpRuntimeOptions; + vi.mocked(runtime.setConfigOption).mockImplementation(async () => { + const guard = created.protocolGuardFactory!(); + const binding = cursorInstructionBinding(options.systemInstructions); + guard("outbound", { id: 0, method: "session/load", params: { sessionId: "backend-1" } }); + guard("inbound", { id: 0, result: mode === "missing" ? {} : { modes: { currentModeId: "agent" }, configOptions: [{ id: "mode", currentValue: "agent" }], _meta: { paperclipCursorInstructions: { + schema: "paperclip.cursor.instructions.v1", digest: binding.digest, byteLength: binding.byteLength, + } } } }); + if (mode === "unsupported") throw new Error("Exact model unsupported"); + }); + const opening = openCodexAcpxRuntime(options, { + createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; }, + }); + if (mode === "valid") { + const port = await opening; expect(options.refreshConsumedCommand).toHaveBeenCalledOnce(); await port.close({ reason: "fixture cleanup" }); + } else { + await expect(opening).rejects.toThrow(mode === "missing" ? /Cursor instruction admission failed/ : /Exact model unsupported/); + expect(options.refreshConsumedCommand).not.toHaveBeenCalled(); expect(runtime.close).toHaveBeenCalled(); + } + expect(runtime.setConfigOption).toHaveBeenCalledExactlyOnceWith({ handle: HANDLE, key: "model", value: options.profile.reportedModelId }); + expect(runtime.startTurn).not.toHaveBeenCalled(); + }); + it.each(["plan", "ask"] as const)("admission explicitly selects %s and renews temporary command authority", async selected => { + const runtime = fakeRuntime(); const options = openOptions(fakeCommand()); + options.profile = { ...options.profile, agent: "cursor" }; options.cursorMode = selected; + options.refreshConsumedCommand = vi.fn(async () => undefined); + let created!: AcpRuntimeOptions; + const binding = cursorInstructionBinding(options.systemInstructions); + const configs = (mode: string) => [{ id: "mode", currentValue: mode }]; + vi.mocked(runtime.setConfigOption).mockImplementation(async input => { + const guard = created.protocolGuardFactory!(); + guard("outbound", { id: 0, method: "session/load", params: { sessionId: "backend-1" } }); + guard("inbound", { id: 0, result: { modes: { currentModeId: "agent" }, configOptions: configs("agent"), _meta: { paperclipCursorInstructions: { + schema: "paperclip.cursor.instructions.v1", digest: binding.digest, byteLength: binding.byteLength, + } } } }); + 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: configs(input.key === "mode" ? selected : "agent") } }); + }); + const port = await openCodexAcpxRuntime(options, { + createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; }, + }); + expect(vi.mocked(runtime.setConfigOption).mock.calls.map(([call]) => [call.key, call.value])).toEqual([["model", options.profile.reportedModelId], ["mode", selected]]); + expect(options.refreshConsumedCommand).toHaveBeenCalledTimes(2); + expect(await port.identity()).toMatchObject({ cursorMode: selected }); + expect(runtime.startTurn).not.toHaveBeenCalled(); + await port.close({ reason: "mode fixture cleanup" }); + }); + it("routes only profile-allowed extensions to the owning turn and expires late responses", async () => { const runtime = fakeRuntime(); const first = pendingExtensionTurn("turn-1"); @@ -63,7 +164,14 @@ describe("Codex ACPX runtime adapter", () => { options.clientCapabilities = { _meta: { cursor: { test: true } } }; const port = await openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(), - createRuntime: (value) => { created = value; return runtime; }, + createRuntime: (value) => { + created = value; + const guard = value.protocolGuardFactory!(); + const binding = cursorInstructionBinding(options.systemInstructions); + guard("outbound", { id: 0, method: "session/new", params: {} }); + guard("inbound", { id: 0, result: { sessionId: "backend-1", modes: { currentModeId: "agent" }, configOptions: [{ id: "mode", currentValue: "agent" }], _meta: { paperclipCursorInstructions: { schema: "paperclip.cursor.instructions.v1", digest: binding.digest, byteLength: binding.byteLength } } } }); + return runtime; + }, }); expect(created.clientCapabilities).toEqual(options.clientCapabilities); expect(JSON.stringify(vi.mocked(runtime.ensureSession).mock.calls)).not.toContain("clientCapabilities"); @@ -418,6 +526,8 @@ describe("Codex ACPX runtime adapter", () => { lastRequestId: "run:turn-1", 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"], @@ -440,6 +550,8 @@ describe("Codex ACPX runtime adapter", () => { lastRequestId: "run:turn-1", 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"], 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 fcc480f74f4..a238e7222f5 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -30,6 +30,8 @@ import { import type { AcpxModelStatus } from "./model-verification.js"; import { AcpxApprovalRequiredError, decideAcpxPermission } from "./permission-policy.js"; import { ACPX_CAPABILITY_PROFILES } from "./capability-profiles.js"; +import { admitCursorInstructions, createCursorInstructionAdmission } from "./cursor-instructions.js"; +import { createCursorModeAdmission, resolveCursorSessionMode } from "./cursor-mode.js"; const VERIFIED_COMMAND_SENTINEL = "paperclip-verified-acpx-command"; const DEFAULT_RUNTIME_CLOSE_TIMEOUT_MS = 2_000; @@ -265,8 +267,9 @@ export async function openQualifiedAcpxRuntime( ); const permissionBoundary: { active: AbortController | null; + hasAdmittedTurn: boolean; handler?: AcpRuntimeOptions["onPermissionRequest"]; - } = { active: null }; + } = { active: null, hasAdmittedTurn: false }; const extensionProfile = ACPX_CAPABILITY_PROFILES[options.profile.agent]; const extensionRequests = new Set(extensionProfile.extensionRequests); const extensionNotifications = new Set(extensionProfile.extensionNotifications); @@ -297,8 +300,18 @@ export async function openQualifiedAcpxRuntime( ); }; const commandLaunches = { count: 0, refreshConsumedCommand: options.refreshConsumedCommand }; + const selectedCursorMode = resolveCursorSessionMode(options.profile.agent, options.cursorMode); + const cursorMode = selectedCursorMode ? createCursorModeAdmission(selectedCursorMode) : null; + const cursorInstructions = options.profile.agent === "cursor" + ? createCursorInstructionAdmission(options.systemInstructions) + : null; const runtimeOptions: GoalAwareAcpRuntimeOptions = { cwd: options.cwd, + ...(cursorInstructions && cursorMode ? { protocolGuardFactory: () => { + const instructions = cursorInstructions.createGuard(); + const mode = cursorMode.createGuard(); + return (direction: "inbound" | "outbound", message: unknown) => { instructions(direction, message); mode(direction, message); }; + } } : {}), sessionStore, agentRegistry: createRegistry({ // Preserve Claude's ACP capability identity. This is metadata only: the @@ -358,6 +371,8 @@ export async function openQualifiedAcpxRuntime( || (rawSessionId !== undefined && rawSessionId !== request.sessionId)) { return { outcome: "reject_once" }; } + if ((permissionBoundary.hasAdmittedTurn && !extensionBoundary.active) + || extensionBoundary.active?.signal.aborted || context.signal.aborted) return { outcome: "cancel" }; const disposition = decideAcpxPermission( options.profile.agent, options.permissionMode, @@ -371,15 +386,16 @@ export async function openQualifiedAcpxRuntime( ); if (disposition === "delegate") { const active = permissionBoundary.active; + const turn = extensionBoundary.active; const handler = permissionBoundary.handler; - if (active && handler && !active.signal.aborted && !context.signal.aborted) { + 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, context.signal]), + signal: AbortSignal.any([active.signal, turn.signal, context.signal]), responseDelivery: context.responseDelivery, }); - if (permissionBoundary.active !== active || active.signal.aborted || context.signal.aborted) { + if (permissionBoundary.active !== active || extensionBoundary.active !== turn || active.signal.aborted || turn.signal.aborted || context.signal.aborted) { return { outcome: "cancel" }; } return decision ?? { outcome: "cancel" }; @@ -439,7 +455,8 @@ export async function openQualifiedAcpxRuntime( runtimeCloseTimeoutMs, ); - const handshake = Promise.resolve() + let handle: AcpRuntimeHandle | null = null; + const ensuredSession = Promise.resolve() .then(() => runtime.ensureSession({ sessionKey: options.providerSessionKey, @@ -455,11 +472,46 @@ export async function openQualifiedAcpxRuntime( : {}), }, }), - ) + ); + const handshake = (cursorInstructions + ? ensuredSession.then(async (ensuredHandle) => { + handle = ensuredHandle; + options.signal?.throwIfAborted(); + await admitCursorInstructions(cursorInstructions, { + providerSpawned: commandLaunches.count > 0, + load: async () => { + // A fresh ACPX manager may reuse a saved record without spawning. + // Its supported exact-model control forces session/load and the + // current instruction acknowledgement before host admission. + if (!runtime.setConfigOption) { + throw new Error("Cursor cold admission requires exact model configuration"); + } + await runtime.setConfigOption({ + handle: ensuredHandle, key: "model", value: options.profile.reportedModelId, + }); + // Match runtimePort.setModel: settle ownership of the temporary + // control connection before retiring its consumed command lease. + await children.verifyLifetimeOwnership(); + }, + refreshCommand: async () => { + options.signal?.throwIfAborted(); + await commandLaunches.refreshConsumedCommand?.(); + }, + }); + if (cursorMode && !cursorMode.isReady()) { + if (!runtime.setConfigOption) throw new Error("Cursor mode admission requires native mode configuration"); + await runtime.setConfigOption({ handle: ensuredHandle, key: "mode", value: selectedCursorMode! }); + cursorInstructions.assertReady(); + cursorMode.assertReady(); + await children.verifyLifetimeOwnership(); + options.signal?.throwIfAborted(); + await commandLaunches.refreshConsumedCommand?.(); + } + return ensuredHandle; + }) : ensuredSession) .catch((error: unknown) => { throw classifySessionEnsureFailure(error); }); - let handle: AcpRuntimeHandle | null = null; let lateCleanup: Promise | null = null; try { const boundedHandshake = boundedSessionHandshake( @@ -469,6 +521,8 @@ export async function openQualifiedAcpxRuntime( handle = options.signal ? await raceRuntimeHandshakeWithAbort(boundedHandshake, options.signal) : await boundedHandshake; + cursorInstructions?.assertReady(); + cursorMode?.assertReady(); // A provider can answer only after the verified sentinel is armed, but do // not admit the session until the owner has observed that exact handoff. await children.verifyLifetimeOwnership(); @@ -523,7 +577,7 @@ export async function openQualifiedAcpxRuntime( return runtimePort( runtime, handle, - requireIdentity(handle), + { ...requireIdentity(handle), ...(selectedCursorMode ? { cursorMode: selectedCursorMode } : {}) }, baseStore, children, runtimeCloseTimeoutMs, @@ -958,7 +1012,7 @@ function runtimePort( runtimeCloseTimeoutMs: number, goalState: AcpxRuntimeGoalState, commandLaunches: { count: number; refreshConsumedCommand?: () => Promise }, - permissionBoundary: { active: AbortController | null; handler?: AcpRuntimeOptions["onPermissionRequest"] }, + permissionBoundary: { active: AbortController | null; hasAdmittedTurn: boolean; handler?: AcpRuntimeOptions["onPermissionRequest"] }, extensionBoundary: AcpxRuntimeExtensionBoundary, ): AcpxRuntimePort { extensionBoundary.sessionIds = new Set([identity.backendSessionId]); @@ -1331,6 +1385,7 @@ function runtimePort( controller.abort(new Error("ACPX extension turn expired")); if (extensionBoundary.active === extensionTurn) extensionBoundary.active = null; }; + permissionBoundary.hasAdmittedTurn = true; permissionBoundary.active = approval; permissionBoundary.handler = input.onPermissionRequest; const finishOwnershipAdmission = @@ -1491,6 +1546,8 @@ 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 ? {} diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.test.ts new file mode 100644 index 00000000000..1e77f46018f --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.test.ts @@ -0,0 +1,228 @@ +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { validateAcpxRichEvent } from "./profile-extensions.js"; +import { PAPERCLIP_QUESTION_RESPONSE_SCHEMA } from "../../contracts/question-set.js"; +import { + createCursorNotificationNormalizer, createCursorProfileExtensionAdapter, createCursorSubagentNormalizer, cursorWorkspaceArtifactReference, + normalizeCursorPlanRequest, normalizeCursorQuestionRequest, normalizeCursorSubagentUpdate, +} from "./cursor-extensions.js"; + +const response = (answers: Record) => ({ schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, answers }); +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); +async function workspace() { const root = await mkdtemp(join(tmpdir(), "cursor-extension-")); roots.push(root); return root; } + +describe("Cursor blocking extension normalization", () => { + const question = { toolCallId: "call-1", title: "Choose scope", questions: [ + { id: "__proto__", prompt: "Which?", options: [{ id: "constructor", label: "A" }, { id: "second", label: "B" }] }, + { id: "many", prompt: "Select all", allowMultiple: true, options: [{ id: "x", label: "X" }, { id: "y", label: "Y" }] }, + ] }; + it("preserves native question/option identity and multi-select without exposing unsafe keys", () => { + const normalized = normalizeCursorQuestionRequest(question); + expect(normalized.accept(response({ "question-1": { selectedOptionIds: ["option-1"] }, "question-2": { selectedOptionIds: ["option-2", "option-1"] } }))).toEqual({ outcome: { outcome: "answered", answers: [ + { questionId: "__proto__", selectedOptionIds: ["constructor"] }, { questionId: "many", selectedOptionIds: ["y", "x"] }, + ] } }); + expect(normalized.cancel()).toEqual({ outcome: { outcome: "cancelled" } }); + expect(normalized.skip("not applicable")).toEqual({ outcome: { outcome: "skipped", reason: "not applicable" } }); + }); + it("rejects incomplete, forged, duplicate and multi-selected single answers", () => { + const normalized = normalizeCursorQuestionRequest(question); + expect(() => normalized.accept(response({}))).toThrow(); + for (const selectedOptionIds of [["forged"], ["option-1", "option-2"], ["option-1", "option-1"]]) { + expect(() => normalized.accept(response({ "question-1": { selectedOptionIds }, "question-2": { selectedOptionIds: ["option-1"] } }))).toThrow(); + } + expect(() => normalizeCursorQuestionRequest({ ...question, questions: [question.questions[0], question.questions[0]] })).toThrow("Duplicate"); + expect(() => normalizeCursorQuestionRequest({ ...question, questions: [{ ...question.questions[0], allowMultiple: "false" }] })).toThrow("boolean"); + }); + it("binds decisions to the full plan revision including phases and todos", () => { + const native = { toolCallId: "plan-1", name: "Migrate", overview: "Two steps", plan: "# Plan\n\nRead, then migrate.", todos: [{ id: "1", content: "Read", status: "pending" }], phases: [{ name: "Delivery", todos: [{ id: "2", content: "Migrate", status: "pending" }] }], isProject: true }; + const first = normalizeCursorPlanRequest(native); + const next = normalizeCursorPlanRequest({ ...native, plan: "# Plan\n\nDelete production data." }); + expect(first.questionSet.description).toContain(native.plan); + expect(first.questionSet.description).toContain("Phase: Delivery"); + expect(first.questionSet.description).toContain("[pending] Migrate"); + expect(first.revision).not.toBe(next.revision); + const accept = response({ [first.questionSet.questions[0]!.id]: { selectedOptionIds: ["accept"] } }); + expect(first.resolve(accept)).toEqual({ outcome: { outcome: "accepted" } }); + expect(() => next.resolve(accept)).toThrow(); + expect(first.resolve(response({ [first.questionSet.questions[0]!.id]: { selectedOptionIds: ["reject"] }, reason: { text: "Too broad" } }))).toEqual({ outcome: { outcome: "rejected", reason: "Too broad" } }); + expect(first.resolve(response({ [first.questionSet.questions[0]!.id]: { selectedOptionIds: ["cancel"] } }))).toEqual(first.cancel()); + }); + it("never truncates or fabricates approval of an oversized plan", () => { + expect(() => normalizeCursorPlanRequest({ toolCallId: "plan", plan: "x".repeat(100_001), todos: [] })).toThrow(); + expect(() => normalizeCursorPlanRequest({ toolCallId: "plan", plan: "x", todos: [{ id: "1", content: "x", status: "mystery" }] })).toThrow(); + const full = "Details\n".repeat(1_000); + expect(normalizeCursorPlanRequest({ toolCallId: "plan", plan: full, todos: [] }).questionSet.description).toBe(full); + expect(() => normalizeCursorPlanRequest({ toolCallId: "plan", plan: "漢".repeat(90_000), todos: [] })).toThrow("byte bound"); + }); +}); + +describe("Cursor rich notifications", () => { + it("merges todos into complete snapshots with increasing revisions and visible cancellation", async () => { + const normalize = createCursorNotificationNormalizer({ workspacePath: await workspace(), turnId: "turn-1" }); + const first = await normalize("cursor/update_todos", { toolCallId: "a", merge: false, todos: [{ id: "a", content: "Build", status: "pending" }] }); + const second = await normalize("cursor/update_todos", { toolCallId: "b", merge: true, todos: [{ id: "b", content: "Ship", status: "cancelled" }] }); + expect(second[0]?.payload).toMatchObject({ planId: first[0]?.payload.planId, revision: 2, complete: false, steps: [{ body: "Build", status: "pending" }, { body: "Ship\n(Cancelled)", status: "blocked" }] }); + const final = await normalize("cursor/update_todos", { toolCallId: "c", merge: false, todos: [{ id: "a", content: "Build", status: "completed" }] }); + expect(final[0]?.payload).toMatchObject({ revision: 3, complete: true, steps: [{ body: "Build", status: "completed" }] }); + }); + it("bounds a valid detailed snapshot before committing, retaining all identities and statuses", async () => { + const normalize = createCursorNotificationNormalizer({ workspacePath: await workspace(), turnId: "large" }); + const todos = Array.from({ length: 65 }, (_, i) => ({ id: `todo-${i}`, content: `${i}: ` + "x".repeat(3_970), status: i === 64 ? "cancelled" : "pending" })); + const request = { toolCallId: "large", merge: false, todos }; + expect(Buffer.byteLength(JSON.stringify(request))).toBeLessThan(256 * 1024); + const events = await normalize("cursor/update_todos", request); + for (const event of events) expect(() => validateAcpxRichEvent(event)).not.toThrow(); + expect(events).toHaveLength(2); + expect(events[1]?.payload.category).toBe("cursor_todo_detail_partial"); + const steps = events[0]!.payload.steps as { stepId: string; body: string; status: string }[]; + const unbounded = structuredClone(events[0]!); + unbounded.payload.steps = steps.map((step, i) => ({ ...step, body: todos[i]!.content + (i === 64 ? "\n(Cancelled)" : "") })); + expect(Buffer.byteLength(JSON.stringify(unbounded))).toBeGreaterThan(240 * 1024); + expect(() => validateAcpxRichEvent(unbounded)).toThrow("Unsupported ACP rich activity event"); + expect(steps).toHaveLength(65); + expect(new Set(steps.map(step => step.stepId)).size).toBe(65); + steps.forEach((step, i) => { + expect(step.body.startsWith(`${i}: `)).toBe(true); + expect(step.body).toContain("[Further todo detail omitted]"); + expect(step.status).toBe(i === 64 ? "blocked" : "pending"); + }); + expect(steps[64]!.body).toMatch(/\(Cancelled\)$/); + const next = await normalize("cursor/update_todos", { toolCallId: "progress", merge: true, todos: [{ ...todos[0], status: "completed" }] }); + for (const event of next) expect(() => validateAcpxRichEvent(event)).not.toThrow(); + expect(next[0]!.payload).toMatchObject({ revision: 2, steps: steps.map((step, i) => ({ stepId: step.stepId, status: i === 0 ? "completed" : step.status })) }); + const small = await normalize("cursor/update_todos", { toolCallId: "replace", merge: false, todos: [todos[0]] }); + expect(small).toHaveLength(1); + expect(small[0]!.payload).toMatchObject({ revision: 3, explanation: null, steps: [{ body: todos[0]!.content }] }); + }); + + it("bounds merged Unicode and escaped detail without poisoning later updates or rejected over-capacity merges", async () => { + const normalize = createCursorNotificationNormalizer({ workspacePath: await workspace(), turnId: "merged" }); + const content = "😀\u0001\\\"".repeat(790); + let events; + for (let batch = 0; batch < 16; batch++) { + const request = { toolCallId: `batch-${batch}`, merge: true, todos: Array.from({ length: 16 }, (_, i) => ({ id: `${batch * 16 + i}`, content, status: "pending" })) }; + expect(Buffer.byteLength(JSON.stringify(request))).toBeLessThan(256 * 1024); + events = await normalize("cursor/update_todos", request); + for (const event of events) expect(() => validateAcpxRichEvent(event)).not.toThrow(); + } + const steps = events![0]!.payload.steps as { stepId: string; body: string }[]; + expect(steps).toHaveLength(256); + expect(steps.every(step => step.body.isWellFormed() && step.body.includes("[Further todo detail omitted]"))).toBe(true); + await expect(normalize("cursor/update_todos", { toolCallId: "overflow", merge: true, todos: [{ id: "extra", content: "Rejected", status: "pending" }] })).rejects.toThrow("256"); + // Shrink all but one item through deltas: full retained detail must return, + // and the rejected merge must neither increment revision nor add an item. + const restored = await normalize("cursor/update_todos", { toolCallId: "shrink", merge: true, todos: Array.from({ length: 255 }, (_, i) => ({ id: `${i + 1}`, content: "Done", status: "completed" })) }); + expect(restored).toHaveLength(1); + expect(() => validateAcpxRichEvent(restored[0]!)).not.toThrow(); + expect(restored[0]!.payload).toMatchObject({ revision: 17, steps: [{ stepId: steps[0]!.stepId, body: content }, ...Array.from({ length: 255 }, () => ({ body: "Done", status: "completed" }))] }); + }); + + it("preserves delegation role/model/task and native duration", async () => { + const normalize = createCursorNotificationNormalizer({ workspacePath: await workspace(), turnId: "turn" }); + const [event] = await normalize("cursor/task", { toolCallId: "call", description: "Find references", prompt: "Search src", subagentType: { custom: "research" }, model: "model-1", agentId: "child-1", durationMs: 312 }); + expect(event).toMatchObject({ eventType: "delegation.completed", payload: { children: [{ role: "research", model: "model-1", summary: "Find references", activitySummary: "Search src\nDuration: 312 ms" }] } }); + }); + it("verifies generated image references and refuses symlinks, escapes, directories and missing files", async () => { + const root = await workspace(); + const inside = join(root, "workspace"); await mkdir(inside); await mkdir(join(inside, "outputs")); + await writeFile(join(inside, "outputs", "image.png"), "image"); await writeFile(join(root, "secret"), "private"); + await symlink(join(root, "secret"), join(inside, "link")); + await symlink(join(inside, "outputs"), join(inside, "linked-dir")); + expect(await cursorWorkspaceArtifactReference(inside, join(inside, "outputs", "image.png"))).toBe("outputs/image.png"); + for (const path of ["../secret", join(root, "secret"), "link", "linked-dir/image.png", "missing", "outputs", "outputs/evil\u0000.png", "C:\\secret"]) expect(await cursorWorkspaceArtifactReference(inside, path)).toBeNull(); + const normalize = createCursorNotificationNormalizer({ workspacePath: inside, turnId: "turn" }); + expect(await normalize("cursor/generate_image", { toolCallId: "image", description: "A diagram", filePath: "outputs/image.png" })).toMatchObject([{ eventType: "artifact.generated", payload: { reference: "outputs/image.png", registered: false } }, { eventType: "provider.notice.recorded", payload: { summary: "A diagram" } }]); + expect(await normalize("cursor/generate_image", { toolCallId: "image", description: "A diagram", filePath: "../secret" })).toMatchObject([{ payload: { reference: null } }, {}, { payload: { category: "cursor_artifact_path_rejected" } }]); + }); + it("maps opt-in subagent lifecycle without treating cancellation as completion", () => { + const base = { subagentSessionId: "child.2", _meta: { cursor: { toolCallId: "task-1", agentId: "child", model: "small" } } }; + expect(normalizeCursorSubagentUpdate({ ...base, sessionUpdate: "subagent_spawned", name: "Explore", task: "Find tests" })).toMatchObject({ eventType: "delegation.started", payload: { status: "running", children: [{ role: "Explore", model: "small", summary: "Find tests" }] } }); + expect(normalizeCursorSubagentUpdate({ ...base, sessionUpdate: "subagent_state_update", state: "cancelled" })).toMatchObject({ eventType: "delegation.completed", payload: { status: "interrupted" } }); + expect(normalizeCursorSubagentUpdate({ sessionUpdate: "agent_message_chunk" })).toBeNull(); + const normalize = createCursorSubagentNormalizer(); + normalize({ ...base, sessionUpdate: "subagent_spawned", name: "Explore", task: "Find tests" }); + expect(normalize({ ...base, sessionUpdate: "subagent_state_update", state: "completed" })).toMatchObject({ payload: { children: [{ role: "Explore", model: "small", summary: "Find tests", status: "completed" }] } }); + }); +}); + +describe("Cursor active-turn extension adapter", () => { + it("roundtrips canonical answers and plan decisions while rejecting implicit acceptance", async () => { + const adapter = createCursorProfileExtensionAdapter({ workspacePath: await workspace(), sessionId: "parent", turnId: "turn" }); + const question = await adapter.request("cursor/ask_question", { sessionId: "parent", toolCallId: "ask", questions: [ + { id: "native", prompt: "Choose", options: [{ id: "native-option", label: "One" }] }, + ] }); + if (!("input" in question)) throw new Error("question was not blocking"); + expect(question.input.resolve({ action: "submit", response: response({ "question-1": { selectedOptionIds: ["option-1"] } }) })).toEqual({ outcome: { outcome: "answered", answers: [{ questionId: "native", selectedOptionIds: ["native-option"] }] } }); + expect(question.input.resolve({ action: "decline" })).toEqual({ outcome: { outcome: "skipped" } }); + expect(question.input.resolve({ action: "cancel" })).toEqual(question.input.cancel()); + expect(() => question.input.resolve({ action: "accept" })).toThrow(); + expect(() => question.input.resolve({ action: "submit", answers: { "question-1": { answers: ["option-1"] } } })).toThrow(); + + const plan = await adapter.request("cursor/create_plan", { sessionId: "parent", toolCallId: "plan", plan: "# Complete plan\n".repeat(1_000), todos: [] }); + if (!("input" in plan)) throw new Error("plan was not blocking"); + expect(plan.input.details.revision).toMatch(/^[a-f0-9]{64}$/); + const id = plan.input.questionSet.questions[0]!.id; + expect(plan.input.resolve({ action: "submit", response: response({ [id]: { selectedOptionIds: ["accept"] } }) })).toEqual({ outcome: { outcome: "accepted" } }); + expect(() => plan.input.resolve({ action: "accept" })).toThrow("displayed revision"); + expect(plan.input.resolve({ action: "decline" })).toEqual({ outcome: { outcome: "rejected" } }); + expect(plan.input.cancel()).toEqual({ outcome: { outcome: "cancelled" } }); + }); + + it("acknowledges native request-shaped activity and preserves its reducer order", async () => { + const adapter = createCursorProfileExtensionAdapter({ workspacePath: await workspace(), sessionId: "parent", turnId: "turn" }); + const first = await adapter.request("cursor/update_todos", { sessionId: "parent", toolCallId: "a", merge: false, todos: [{ id: "a", content: "Read", status: "pending" }] }); + expect(first).toMatchObject({ response: {}, events: [{ eventType: "plan.updated", payload: { revision: 1 } }] }); + const second = await adapter.notification("cursor/update_todos", { sessionId: "parent", toolCallId: "b", merge: true, todos: [{ id: "b", content: "Write", status: "pending" }] }); + expect(second).toMatchObject([{ payload: { revision: 2, steps: [{ body: "Read" }, { body: "Write" }] } }]); + await expect(adapter.request("cursor/update_todos", { sessionId: "other" })).rejects.toThrow("stale parent"); + await expect(adapter.notification("cursor/task", { sessionId: "other" })).rejects.toThrow("stale parent"); + await expect(adapter.request("cursor/unknown", { sessionId: "parent" })).rejects.toThrow("Unsupported"); + }); + + it("requires a known child spawn and immutable origin within the active parent", async () => { + const context = { workspacePath: await workspace(), sessionId: "parent", turnId: "turn" }; + const adapter = createCursorProfileExtensionAdapter(context); + const update = { subagentSessionId: "child", _meta: { cursor: { toolCallId: "task", agentId: "native-child", model: "small" } } }; + const state = { ...update, sessionUpdate: "subagent_state_update", state: "completed" }; + await expect(adapter.notification("cursor/subagent_update", { sessionId: "parent", update: state })).rejects.toThrow("no spawn"); + const spawned = { ...update, sessionUpdate: "subagent_spawned", name: "Research", task: "Inspect source" }; + expect(await adapter.notification("cursor/subagent_update", { sessionId: "parent", update: spawned })).toMatchObject([{ eventType: "delegation.started", payload: { children: [{ role: "Research", model: "small" }] } }]); + expect(await adapter.notification("cursor/subagent_update", { sessionId: "parent", update: state })).toMatchObject([{ eventType: "delegation.completed", payload: { children: [{ role: "Research", summary: "Inspect source" }] } }]); + await expect(adapter.notification("cursor/subagent_update", { sessionId: "other", update: state })).rejects.toThrow("stale parent"); + await expect(adapter.notification("cursor/subagent_update", { sessionId: "parent", update: { ...state, _meta: { cursor: { ...update._meta.cursor, toolCallId: "forged" } } } })).rejects.toThrow("origin identity"); + await expect(adapter.notification("cursor/subagent_update", { sessionId: "parent", update: spawned })).rejects.toThrow("duplicated"); + await expect(createCursorProfileExtensionAdapter({ ...context, turnId: "next" }).notification("cursor/subagent_update", { sessionId: "parent", update: state })).rejects.toThrow("no spawn"); + }); + + it.each(["😀".repeat(2_500), "😀".repeat(5_000), "漢😀".repeat(2_000)])("bounds Unicode child activity for UTF-16 consumers without splitting code points (%#)", async message => { + const adapter = createCursorProfileExtensionAdapter({ workspacePath: await workspace(), sessionId: "parent", turnId: "turn" }); + await adapter.notification("cursor/subagent_update", { sessionId: "parent", update: { sessionUpdate: "subagent_spawned", subagentSessionId: "child", name: "Explore", task: "", _meta: { cursor: { toolCallId: "task", agentId: "agent" } } } }); + const events = await adapter.notification("cursor/subagent_update", { sessionId: "parent", childSessionId: "child", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: message } } }); + const event = events[0]!; + expect(() => validateAcpxRichEvent(event)).not.toThrow(); + const summary = (event.payload.children as { activitySummary: string }[])[0]!.activitySummary; + expect(summary.length).toBeLessThanOrEqual(4_000); + expect(summary.isWellFormed()).toBe(true); + expect(summary).toMatch(/^\[Earlier child activity omitted\]\n/); + const tail = summary.slice("[Earlier child activity omitted]\n".length); + expect(message.endsWith(tail)).toBe(true); + }); + + it("attributes nested child transcripts, reports partial tool detail, and settles disconnected children honestly", async () => { + const adapter = createCursorProfileExtensionAdapter({ workspacePath: await workspace(), sessionId: "parent", turnId: "turn" }); + const child = { sessionUpdate: "subagent_spawned", subagentSessionId: "child", name: "Explore", task: "", _meta: { cursor: { toolCallId: "task", agentId: "agent" } } }; + await adapter.notification("cursor/subagent_update", { sessionId: "parent", update: child }); + const grandchild = { ...child, subagentSessionId: "grandchild", _meta: { cursor: { toolCallId: "task-2", agentId: "agent-2" } } }; + expect(await adapter.notification("cursor/subagent_update", { sessionId: "parent", parentSessionId: "child", update: grandchild })).toMatchObject([{ payload: { children: [{ activitySummary: expect.stringContaining("Parent child:") }] } }]); + const text = await adapter.notification("cursor/subagent_update", { sessionId: "parent", childSessionId: "grandchild", parentSessionId: "child", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "漢".repeat(5_000) } } }); + expect(text[0]?.payload).toMatchObject({ children: [{ activitySummary: expect.stringContaining("Earlier child activity omitted") }] }); + const tool = await adapter.notification("cursor/subagent_update", { sessionId: "parent", childSessionId: "child", update: { sessionUpdate: "tool_call", toolCallId: "read", title: "Read src", status: "completed", rawInput: { path: "src/index.ts" } } }); + expect(tool).toMatchObject([{ eventType: "delegation.updated", payload: { children: [{ activitySummary: "Tool Read src: completed" }] } }, { eventType: "provider.notice.recorded", payload: { category: "cursor_child_detail_partial" } }]); + const disconnected = await adapter.notification("cursor/subagent_update", { sessionId: "parent", update: { ...child, sessionUpdate: "subagent_state_update", state: "disconnected" } }); + expect(disconnected).toMatchObject([{ payload: { status: "failed", children: [{ status: "failed", activitySummary: expect.stringContaining("disconnected before settling") }] } }]); + await expect(adapter.notification("cursor/subagent_update", { sessionId: "parent", childSessionId: "grandchild", parentSessionId: "wrong", update: {} })).rejects.toThrow("unknown parent"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.ts new file mode 100644 index 00000000000..53383b344fb --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.ts @@ -0,0 +1,492 @@ +import { createHash } from "node:crypto"; +import { lstat, realpath } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; + +import { + PAPERCLIP_QUESTION_SET_SCHEMA, + parsePaperclipQuestionResponse, + parsePaperclipQuestionSet, + type PaperclipQuestionSet, +} from "../../contracts/question-set.js"; +import type { CanonicalProviderEvent } from "../../provider-events.js"; +import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js"; + +export const CURSOR_EXTENSION_REQUEST_METHODS = [ + "cursor/ask_question", "cursor/create_plan", +] as const; +// The pinned binary sends these with extMethod (a JSON-RPC ID), even though +// Cursor documents them as notifications. Acknowledge immediately with {}. +export const CURSOR_EXTENSION_NOTIFICATION_METHODS = [ + "cursor/update_todos", "cursor/task", "cursor/generate_image", +] as const; +export const CURSOR_CLIENT_CAPABILITIES = { _meta: { subagents: true } } as const; + +type CursorExtensionInput = { + method: string; + questionSet: PaperclipQuestionSet; + details: Record; + resolve(resolution: HarnessRuntimeRequestResolution): Record; + cancel(): Record; +}; +type CursorExtensionResult = { input: CursorExtensionInput } + | { events: CanonicalProviderEvent[]; response: Record }; + +/** One adapter belongs to exactly one active session/turn, never a warm process. */ +export function createCursorProfileExtensionAdapter(context: { + workspacePath: string; sessionId: string; turnId: string; +}) { + const normalizeNotification = createCursorNotificationNormalizer(context); + const normalizeSubagent = createCursorSubagentNormalizer(); + const children = new Map(); + const assertSession = (params: Record) => { + if (params.sessionId !== context.sessionId) throw new Error("Cursor extension has a stale parent session"); + }; + return { + async request(method: string, params: Record): Promise { + assertSession(params); + if (method === "cursor/ask_question") { + const question = normalizeCursorQuestionRequest(params); + return { input: { + method, questionSet: question.questionSet, details: { toolCallId: question.toolCallId }, + cancel: question.cancel, + resolve(resolution) { + if (resolution.action === "cancel") return question.cancel(); + if (resolution.action === "decline") return question.skip(); + if (resolution.action !== "submit" || !("response" in resolution)) throw new Error("Cursor questions require a complete canonical answer"); + return question.resolve(resolution.response); + }, + } }; + } + if (method === "cursor/create_plan") { + const plan = normalizeCursorPlanRequest(params); + return { input: { + method, questionSet: plan.questionSet, details: { toolCallId: plan.toolCallId, revision: plan.revision }, + cancel: plan.cancel, + resolve(resolution) { + if (resolution.action === "cancel") return plan.cancel(); + if (resolution.action === "decline") return { outcome: { outcome: "rejected" } }; + if (resolution.action !== "submit" || !("response" in resolution)) throw new Error("Cursor plan decisions require the displayed revision"); + return plan.resolve(resolution.response); + }, + } }; + } + if (!(CURSOR_EXTENSION_NOTIFICATION_METHODS as readonly string[]).includes(method)) throw new Error("Unsupported Cursor extension request"); + return { events: await normalizeNotification(method, params), response: {} }; + }, + async notification(method: string, params: Record): Promise { + assertSession(params); + if (method !== "cursor/subagent_update") return normalizeNotification(method, params); + // The transport synthesizes this method only from the two pinned native + // session/update variants, after checking the active parent connection. + const update = object(params.update); + const parentSessionId = optionalText(params.parentSessionId, "parentSessionId", 1_000) ?? context.sessionId; + if (parentSessionId !== context.sessionId && !children.has(parentSessionId)) throw new Error("Cursor child has an unknown parent"); + if (params.childSessionId !== undefined) { + const childId = requiredText(params.childSessionId, "childSessionId", 1_000); + const child = children.get(childId); + if (!child || child.parentSessionId !== parentSessionId) throw new Error("Cursor child activity has no matching spawn"); + const summary = cursorChildActivitySummary(update); + const combined = [child.activity, summary.text].filter(Boolean).join("\n"); + child.activity = boundedChildActivity(combined); + const event = structuredClone(child.event); + event.eventType = "delegation.updated"; + const nested = object((event.payload.children as unknown[])[0]); + nested.activitySummary = child.activity; + child.event = event; + return [event, ...(summary.gap ? [notice(`${childId}:${String(update.sessionUpdate)}`, "cursor_child_detail_partial", summary.gap)] : [])]; + } + const id = requiredText(update.subagentSessionId, "subagentSessionId", 1_000); + if (id === context.sessionId) throw new Error("Cursor child cannot impersonate its parent session"); + const metadata = object(object(update._meta).cursor); + const toolCallId = requiredText(metadata.toolCallId, "subagent toolCallId", 1_000); + const agentId = optionalText(metadata.agentId, "subagent agentId", 1_000); + const previous = children.get(id); + if (update.sessionUpdate === "subagent_spawned") { + if (previous || children.size >= 256) throw new Error("Cursor child session identity is duplicated or over capacity"); + } else if (update.sessionUpdate !== "subagent_state_update" || !previous) { + throw new Error("Cursor child update has no spawn in this active turn"); + } + if (previous && (previous.toolCallId !== toolCallId || previous.agentId !== agentId || previous.parentSessionId !== parentSessionId)) throw new Error("Cursor child update changed its origin identity"); + const event = normalizeSubagent(update); + if (!event) throw new Error("Unsupported Cursor child update"); + const nested = object((event.payload.children as unknown[])[0]); + const activity = boundedChildActivity([parentSessionId === context.sessionId ? undefined : `Parent child: ${stableId(parentSessionId)}`, previous?.activity, nested.activitySummary].filter(Boolean).join("\n")); + nested.activitySummary = activity || null; + children.set(id, { toolCallId, agentId, parentSessionId, event, activity }); + return [event]; + }, + }; +} + +type CursorQuestionResult = { outcome: + | { outcome: "answered"; answers: Array<{ questionId: string; selectedOptionIds: string[] }> } + | { outcome: "skipped"; reason?: string } + | { outcome: "cancelled" } +}; +type CursorPlanResult = { outcome: + | { outcome: "accepted" } + | { outcome: "rejected"; reason?: string } + | { outcome: "cancelled" } +}; +type Todo = { id: string; content: string; status: "pending" | "in_progress" | "completed" | "cancelled" }; + +/** Native IDs are mapped through opaque UI IDs, including prototype-like keys. */ +export function normalizeCursorQuestionRequest(value: unknown): { + toolCallId: string; + questionSet: PaperclipQuestionSet; + accept(response: unknown): CursorQuestionResult; + resolve(response: unknown): CursorQuestionResult; + cancel(): CursorQuestionResult; + skip(reason?: string): CursorQuestionResult; +} { + const request = object(value); + const toolCallId = requiredText(request.toolCallId, "toolCallId", 1_000); + const nativeQuestions = array(request.questions, "questions", 64, true); + const seenQuestions = new Set(); + const bindings = nativeQuestions.map((raw, index) => { + const question = object(raw); + const nativeId = unique(requiredText(question.id, "question.id", 1_000), seenQuestions, "question ID"); + const seenOptions = new Set(); + if (question.allowMultiple !== undefined && typeof question.allowMultiple !== "boolean") { + throw new Error("Cursor allowMultiple must be boolean"); + } + const options = array(question.options, "options", 128, true).map((rawOption, optionIndex) => { + const option = object(rawOption); + return { + nativeId: unique(requiredText(option.id, "option.id", 1_000), seenOptions, "option ID"), + id: `option-${optionIndex + 1}`, + label: requiredText(option.label, "option.label", 1_000), + }; + }); + return { + nativeId, options, + question: { + id: `question-${index + 1}`, + prompt: requiredText(question.prompt, "question.prompt", 4_000), + required: true, + answerMode: question.allowMultiple === true ? "multi_select" as const : "single_select" as const, + options: options.map(({ id, label }) => ({ id, label })), + }, + }; + }); + const questionSet = boundedQuestionSet({ + schema: PAPERCLIP_QUESTION_SET_SCHEMA, + title: optionalText(request.title, "title", 1_000) ?? "Cursor needs input", + questions: bindings.map(({ question }) => question), + }); + const accept = (response: unknown): CursorQuestionResult => { + const parsed = parsePaperclipQuestionResponse(questionSet, response); + return { outcome: { outcome: "answered", answers: bindings.map(binding => ({ + questionId: binding.nativeId, + selectedOptionIds: (parsed.answers[binding.question.id]?.selectedOptionIds ?? []).map(id => { + const option = binding.options.find(candidate => candidate.id === id); + if (!option) throw new Error("Cursor question option is no longer available"); + return option.nativeId; + }), + })) } }; + }; + return { + toolCallId, questionSet, accept, resolve: accept, + cancel: () => ({ outcome: { outcome: "cancelled" } }), + skip: (reason?: string) => ({ outcome: { outcome: "skipped", ...(reason === undefined ? {} : { reason: requiredText(reason, "reason", 4_000) }) } }), + }; +} + +/** The approval question ID binds the entire displayed revision, not its title. */ +export function normalizeCursorPlanRequest(value: unknown): { + toolCallId: string; + revision: string; + questionSet: PaperclipQuestionSet; + resolve(response: unknown): CursorPlanResult; + cancel(): CursorPlanResult; +} { + const request = object(value); + const toolCallId = requiredText(request.toolCallId, "toolCallId", 1_000); + const plan = requiredText(request.plan, "plan", 100_000); + const name = optionalText(request.name, "name", 1_000); + const overview = optionalText(request.overview, "overview", 4_000); + const todos = parseTodos(request.todos); + const phases = request.phases === undefined ? [] : array(request.phases, "phases", 64).map(value => { + const phase = object(value); + return { name: requiredText(phase.name, "phase.name", 1_000), todos: parseTodos(phase.todos) }; + }); + if (request.isProject !== undefined && typeof request.isProject !== "boolean") throw new Error("Cursor isProject must be boolean"); + const description = [overview, plan, todos.length ? `Todos\n${renderTodos(todos)}` : undefined, + ...phases.map(phase => `Phase: ${phase.name}\n${renderTodos(phase.todos)}`), + request.isProject === true ? "This is a project plan." : undefined, + ].filter((part): part is string => part !== undefined).join("\n\n"); + // Reject rather than truncating a document that a person must approve. + requiredText(description, "complete plan presentation", 100_000); + const revision = createHash("sha256").update(JSON.stringify({ toolCallId, name, overview, plan, todos, phases, isProject: request.isProject ?? false })).digest("hex"); + const questionId = `plan-${revision}`; + const questionSet = boundedQuestionSet({ + schema: PAPERCLIP_QUESTION_SET_SCHEMA, + title: name ?? "Review Cursor's plan", description, submitLabel: "Send decision", + questions: [{ id: questionId, prompt: "How should Cursor proceed with this plan?", required: true, + answerMode: "single_select", options: [ + { id: "accept", label: "Accept plan" }, + { id: "reject", label: "Reject plan" }, + { id: "cancel", label: "Cancel plan request" }, + ] }, { id: "reason", prompt: "Reason for rejecting the plan (optional)", required: false, + answerMode: "text", textValidation: { maxLength: 4_000 } }], + }); + return { + toolCallId, revision, questionSet, + resolve(response: unknown): CursorPlanResult { + const parsed = parsePaperclipQuestionResponse(questionSet, response); + const decision = parsed.answers[questionId]?.selectedOptionIds?.[0]; + const reason = parsed.answers.reason?.text; + if (decision === "accept") { + if (reason?.trim()) throw new Error("Cursor's accepted outcome cannot carry feedback; remove feedback or reject the plan"); + return { outcome: { outcome: "accepted" } }; + } + if (decision === "reject") return { outcome: { outcome: "rejected", ...(reason ? { reason } : {}) } }; + return { outcome: { outcome: "cancelled" } }; + }, + cancel: () => ({ outcome: { outcome: "cancelled" } }), + }; +} + +const RICH_EVENT_BYTE_LIMIT = 240 * 1024; +const TODO_DETAIL_OMITTED = "\n[Further todo detail omitted]"; + +/** Todo activity is a display snapshot, never the document sent for approval. */ +function boundTodoProjection(event: CanonicalProviderEvent, todos: Todo[]): CanonicalProviderEvent[] { + const fits = () => Buffer.byteLength(JSON.stringify(event)) <= RICH_EVENT_BYTE_LIMIT; + if (fits()) return [event]; + const summary = "Todo detail shortened to fit the activity limit; all todo identities and statuses are preserved."; + event.payload.explanation = summary; + const points = todos.map(todo => Array.from(todo.content)); + const prefixes = points.map(parts => { + let units = 0; + return parts.filter(point => { units += point.length; return units <= 3_940; }); + }); + const project = (limit: number) => { + event.payload.steps = todos.map((todo, index) => ({ + stepId: stableId(todo.id), + body: (points[index]!.length <= limit ? todo.content : prefixes[index]!.slice(0, limit).join("") + TODO_DETAIL_OMITTED) + + (todo.status === "cancelled" ? "\n(Cancelled)" : ""), + status: todo.status === "cancelled" ? "blocked" : todo.status, + })); + }; + // Measure serialized bytes (including JSON escaping), not string length. + // A common prefix bound retains useful detail for every todo and never splits + // a Unicode code point. At most 12 probes for the native 3,980-unit bound. + let low = 0; + let high = 3_980; + while (low < high) { + const mid = Math.ceil((low + high) / 2); + project(mid); + if (fits()) low = mid; + else high = mid - 1; + } + project(low); + if (!fits()) throw new Error("Cursor todo identities exceed the activity byte bound"); + return [event, notice(`${event.itemId}:todo-detail`, "cursor_todo_detail_partial", summary)]; +} + +/** Create one reducer per active turn; Cursor todo updates are deltas. */ +export function createCursorNotificationNormalizer(input: { workspacePath: string; turnId: string }) { + let todos = new Map(); + let revision = 0; + const planId = stableId(`cursor-plan:${input.turnId}`); + return async (method: string, value: unknown): Promise => { + const request = object(value); + const toolCallId = requiredText(request.toolCallId, "toolCallId", 1_000); + const itemId = stableId(toolCallId); + if (method === "cursor/update_todos") { + if (typeof request.merge !== "boolean") throw new Error("Cursor todo merge must be boolean"); + const next = request.merge ? new Map(todos) : new Map(); + for (const todo of parseTodos(request.todos)) next.set(todo.id, todo); + if (next.size > 256) throw new Error("Cursor todo snapshot exceeds 256 items"); + const event: CanonicalProviderEvent = { eventType: "plan.updated", itemId, payload: { + schema: "paperclip.plan.updated.v1", planId, revision: revision + 1, + explanation: null, + steps: Array.from(next.values(), todo => ({ stepId: stableId(todo.id), + body: todo.status === "cancelled" ? `${todo.content}\n(Cancelled)` : todo.content, + status: todo.status === "cancelled" ? "blocked" : todo.status })), + complete: next.size > 0 && Array.from(next.values()).every(todo => todo.status === "completed"), + syncStatus: "not_applicable", documentRevision: null, + } }; + const events = boundTodoProjection(event, [...next.values()]); + // Keep complete native detail for future deltas, but commit only after the + // entire display snapshot fits the rich-event boundary. + todos = next; + revision++; + return events; + } + if (method === "cursor/task") { + const subtype = typeof request.subagentType === "string" ? request.subagentType : object(request.subagentType).custom; + const durationMs = request.durationMs; + if (durationMs !== undefined && (!Number.isSafeInteger(durationMs) || Number(durationMs) < 0)) throw new Error("Cursor task duration must be a nonnegative integer"); + return [{ eventType: "delegation.completed", itemId, payload: { + schema: "paperclip.delegation.v1", delegationId: itemId, action: "spawn", status: "completed", + children: [{ childId: stableId(optionalText(request.agentId, "agentId", 1_000) ?? toolCallId), + role: optionalText(subtype, "subagentType", 160) ?? null, + model: optionalText(request.model, "model", 240) ?? null, status: "completed", + summary: requiredText(request.description, "description", 4_000), + activitySummary: [requiredText(request.prompt, "prompt", 3_800), durationMs === undefined ? undefined : `Duration: ${durationMs} ms`].filter(Boolean).join("\n"), + }], + } }]; + } + if (method === "cursor/generate_image") { + const description = requiredText(request.description, "description", 4_000); + const filePath = optionalText(request.filePath, "filePath", 4_096); + const reference = filePath === undefined ? null : await cursorWorkspaceArtifactReference(input.workspacePath, filePath); + const references = request.referenceImagePaths === undefined ? [] : array(request.referenceImagePaths, "referenceImagePaths", 16); + const viewed: CanonicalProviderEvent[] = []; + for (let index = 0; index < references.length; index++) { + const source = requiredText(references[index], "referenceImagePath", 4_096); + const reference = await cursorWorkspaceArtifactReference(input.workspacePath, source); + const referenceId = stableId(`${toolCallId}:reference:${index}`); + viewed.push({ eventType: "artifact.viewed", itemId: referenceId, payload: { + schema: "paperclip.artifact.viewed.v1", artifactId: referenceId, reference, mediaType: "image/*", title: "Cursor image reference", + } }); + } + return [{ eventType: "artifact.generated", itemId, payload: { + schema: "paperclip.artifact.generated.v1", artifactId: itemId, + status: "completed", reference, mediaType: "image/*", registered: false, failure: null, + } }, notice(`${toolCallId}:description`, "cursor_image", description), + ...viewed, + ...(filePath && reference === null ? [notice(`${toolCallId}:path`, "cursor_artifact_path_rejected", "Cursor reported an image location that could not be verified inside this workspace.", "warning")] : [])]; + } + throw new Error(`Unsupported Cursor notification method ${method}`); + }; +} + +/** References are hints, never instructions to read/upload paths. Revalidate on use. */ +export async function cursorWorkspaceArtifactReference(workspacePath: string, filePath: string): Promise { + if (!filePath || /[\u0000-\u001f\u007f]/.test(filePath) || filePath.includes("\\")) return null; + try { + const workspace = await realpath(workspacePath); + // macOS commonly presents /var while realpath returns /private/var. + let lexical = relative(resolve(workspacePath), resolve(workspacePath, filePath)); + if (!contained(lexical) && isAbsolute(filePath)) lexical = relative(workspace, filePath); + if (!contained(lexical)) return null; + const target = resolve(workspace, lexical); + const physical = await realpath(target); + const physicalRelative = relative(workspace, physical); + if (!contained(physicalRelative) || physical !== target || !(await lstat(target)).isFile()) return null; + return physicalRelative.split(sep).join("/"); + } catch { return null; } +} + +/** Call only after the host has verified parent-session/child-session ownership. */ +export function normalizeCursorSubagentUpdate(value: unknown): CanonicalProviderEvent | null { + const update = object(value); + if (update.sessionUpdate !== "subagent_spawned" && update.sessionUpdate !== "subagent_state_update") return null; + const metadata = object(object(update._meta).cursor); + const itemId = stableId(requiredText(metadata.toolCallId, "subagent toolCallId", 1_000)); + const state = update.sessionUpdate === "subagent_spawned" ? "running" : update.state; + const status = state === "cancelled" ? "interrupted" : state === "disconnected" ? "failed" : state; + if (!["running", "completed", "failed", "interrupted"].includes(String(status))) throw new Error("Unknown Cursor subagent state"); + return { eventType: status === "running" ? "delegation.started" : "delegation.completed", itemId, payload: { + schema: "paperclip.delegation.v1", delegationId: itemId, action: "spawn", status, + children: [{ childId: stableId(requiredText(update.subagentSessionId, "subagentSessionId", 1_000)), + role: optionalText(update.name, "name", 160) ?? null, model: optionalText(metadata.model, "model", 240) ?? null, + status, summary: update.task === "" ? null : optionalText(update.task, "task", 4_000) ?? null, + activitySummary: state === "disconnected" ? "Cursor child disconnected before settling." : null }], + } }; +} + +function cursorChildActivitySummary(update: Record): { text: string; gap?: string } { + const kind = requiredText(update.sessionUpdate, "child update kind", 160); + if (["agent_message_chunk", "agent_thought_chunk", "user_message_chunk"].includes(kind)) { + const content = object(update.content); + if (content.type === "text" && typeof content.text === "string" && content.text.length <= 65_536) { + return { text: `${kind === "agent_thought_chunk" ? "Reasoning" : kind === "user_message_chunk" ? "Input" : "Output"}: ${content.text}` }; + } + return { text: `Child ${kind} contains non-text content.`, gap: "Cursor child media is reported as activity; nested transcript media rendering is not supported yet." }; + } + if (kind === "tool_call" || kind === "tool_call_update") { + const toolCallId = requiredText(update.toolCallId, "child toolCallId", 1_000); + const title = optionalText(update.title, "child tool title", 4_000) ?? toolCallId; + const status = optionalText(update.status, "child tool status", 160) ?? "running"; + return { text: `Tool ${title}: ${status}`, gap: "Cursor child tool lifecycle is attributed to the child; raw input, output, locations and diffs need a nested tool surface and are not rendered in this summary." }; + } + if (kind === "plan") { + const entries = array(update.entries, "child plan entries", 256); + return { text: `Child plan: ${entries.map(value => { const entry = object(value); return `[${requiredText(entry.status, "child plan status", 160)}] ${requiredText(entry.content, "child plan step", 4_000)}`; }).join("; ")}` }; + } + return { text: `Child activity: ${kind}`, gap: `Cursor child update ${kind} was retained as a kind notice; its fields need a nested transcript surface.` }; +} + +function boundedChildActivity(text: string): string { + if (text.length <= 4_000) return text; + const prefix = "[Earlier child activity omitted]\n"; + const points = Array.from(text); + const tail: string[] = []; + let length = prefix.length; + // Bound both UTF-16 consumers and the schema's Unicode code-point count. + // Keep each complete code point when retaining the most recent activity. + for (let index = points.length - 1; index >= 0; index -= 1) { + const point = points[index]!; + if (length + point.length > 4_000) break; + tail.push(point); + length += point.length; + } + return prefix + tail.reverse().join(""); +} + +/** Preserve identity across delta state updates; use a fresh reducer per turn. */ +export function createCursorSubagentNormalizer() { + const children = new Map>(); + return (value: unknown): CanonicalProviderEvent | null => { + const update = object(value); + if (update.sessionUpdate !== "subagent_spawned" && update.sessionUpdate !== "subagent_state_update") return null; + const id = requiredText(update.subagentSessionId, "subagentSessionId", 1_000); + const previous = children.get(id); + if (!previous && children.size >= 256) throw new Error("Cursor subagent inventory exceeds 256 children"); + const merged = { ...previous, ...update, _meta: { + cursor: { ...(previous ? object(object(previous._meta).cursor) : {}), ...object(object(update._meta).cursor) }, + } }; + const normalized = normalizeCursorSubagentUpdate(merged); + children.set(id, merged); + return normalized; + }; +} + +function notice(id: string, category: string, summary: string, severity: "info" | "warning" = "info"): CanonicalProviderEvent { + const itemId = stableId(id); + return { eventType: "provider.notice.recorded", itemId, payload: { + schema: "paperclip.provider.notice.v1", noticeId: itemId, severity, category, + scope: "turn", recoverable: true, userActionable: false, summary, + } }; +} +function boundedQuestionSet(value: unknown): PaperclipQuestionSet { + const parsed = parsePaperclipQuestionSet(value); + // Match the durable Rust question payload bound, including UTF-8 expansion. + if (Buffer.byteLength(JSON.stringify(parsed)) > 196 * 1024) throw new Error("Cursor question presentation exceeds the durable input byte bound"); + return parsed; +} +function parseTodos(value: unknown): Todo[] { + const seen = new Set(); + return array(value, "todos", 256).map(raw => { + const todo = object(raw); + if (!["pending", "in_progress", "completed", "cancelled"].includes(String(todo.status))) throw new Error("Unknown Cursor todo status"); + return { id: unique(requiredText(todo.id, "todo.id", 1_000), seen, "todo ID"), + content: requiredText(todo.content, "todo.content", 3_980), status: todo.status as Todo["status"] }; + }); +} +function renderTodos(todos: Todo[]): string { return todos.map(todo => `- [${todo.status}] ${todo.content}`).join("\n"); } +function stableId(value: string): string { return `cursor-${createHash("sha256").update(value).digest("hex")}`; } +function contained(path: string): boolean { return path !== "" && path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path); } +function object(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Cursor payload must be an object"); + return value as Record; +} +function array(value: unknown, field: string, max: number, nonempty = false): unknown[] { + if (!Array.isArray(value) || value.length > max || (nonempty && value.length === 0)) throw new Error(`Cursor ${field} must contain ${nonempty ? "1" : "0"} through ${max} items`); + return value; +} +function optionalText(value: unknown, field: string, max: number): string | undefined { + return value === undefined ? undefined : requiredText(value, field, max); +} +function requiredText(value: unknown, field: string, max: number): string { + if (typeof value !== "string" || value.length === 0 || value.length > max || value.includes("\0")) throw new Error(`Cursor ${field} must be nonempty text of at most ${max} characters`); + return value; +} +function unique(value: string, seen: Set, field: string): string { + if (seen.has(value)) throw new Error(`Duplicate Cursor ${field}`); + seen.add(value); return value; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-installation.test.ts new file mode 100644 index 00000000000..f9d607c78bd --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-installation.test.ts @@ -0,0 +1,34 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { CURSOR_PINNED_VERSION, cursorNativeDistributionSpec, verifyCursorInstallation } from "./cursor-installation.js"; +import { QUALIFIED_ACPX_PROFILES } from "./qualified-profiles.js"; + +it("matches build materializer pins and launches only package-owned complete distributions", async () => { + const manifest = JSON.parse(await readFile(new URL("../../../cursor-distributions.json", import.meta.url), "utf8")); + expect(manifest.version).toBe(CURSOR_PINNED_VERSION); + for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"]] as const) { + const spec = cursorNativeDistributionSpec(platform, architecture); + expect(spec.expectedClosureSha256).toBe(manifest.platforms[`${platform}-${architecture}`].closureSha256); + expect(spec.distributionRoot).toBe(new URL(`../../../provider-assets/cursor/${platform}-${architecture}`, import.meta.url).pathname); + expect(spec.manifestPath).toBe(join(spec.distributionRoot, ".paperclip-cursor-closure.json")); + expect(spec.executable).toBe("node"); + expect(spec.entrypoint).toBe("index.js"); + expect(spec.fixedArguments).toEqual(["--disable-project-configs", "--disable-auto-update", "acp"]); + (spec.fixedArguments as string[]).push("--force"); + expect(cursorNativeDistributionSpec(platform, architecture).fixedArguments).not.toContain("--force"); + } +}); + +it("rejects profile substitutions before reading any native distribution", async () => { + const base = { ...QUALIFIED_ACPX_PROFILES.cursor, qualificationModel: "explicit", reportedModelId: "explicit" }; + for (const profile of [QUALIFIED_ACPX_PROFILES.cursor, { ...base, agentServerVersion: "latest" }, { ...base, commandDigest: "forged" }, { ...base, reportedModelId: "different" }, { ...base, agentProfileVersion: 7 as const }, { ...base, agentProfileVersion: 8 as const }, { ...base, agentProfileVersion: 9 as const }]) { + await expect(verifyCursorInstallation(profile)).rejects.toThrow("exact pinned profile"); + } +}); + +it("fails closed on unknown platforms instead of ambient executable discovery", () => { + expect(() => cursorNativeDistributionSpec("linux", "arm64")).toThrow("no pinned distribution"); + expect(() => cursorNativeDistributionSpec("win32", "x64")).toThrow("no pinned distribution"); + expect(() => cursorNativeDistributionSpec("darwin", "../../workspace")).toThrow("no pinned distribution"); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-installation.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-installation.ts new file mode 100644 index 00000000000..0f3aa3d68af --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-installation.ts @@ -0,0 +1,45 @@ +import { join } from "node:path"; + +import { verifyNativeAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js"; +import type { NativeAcpxDistributionInput } from "./native-distribution-integrity.js"; +import { CURSOR_FIXED_ARGUMENTS } from "./cursor-launch-policy.js"; +import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-profiles.js"; +import { resolveRunnerProviderAssetsRoot } from "./provider-assets-root.js"; + +export const CURSOR_PINNED_VERSION = "2026.09.26-dd393fe"; +const CLOSURE_PINS = Object.freeze({ + "darwin-arm64": "257424bd48e35412091c6adfc61e4648e836757ec1d240d890bba81a24918c30", + "darwin-x64": "6f28c799c5afdc64fbdff8a2157f565f17ae7615efe014ac389d63bf70cf2be2", + "linux-x64": "eadb8bb8ffb0450455b15b88c9b230307a9e149958a0c452d16dd157b4633d74", +}); + +/** Trusted package assets only: no workspace, PATH, executable override or env root. */ +export function cursorNativeDistributionSpec( + platform: NodeJS.Platform = process.platform, + architecture: string = process.arch, +): NativeAcpxDistributionInput { + const key = `${platform}-${architecture}`; + if (!Object.hasOwn(CLOSURE_PINS, key)) throw new Error(`Cursor ${CURSOR_PINNED_VERSION} has no pinned distribution for ${key}`); + const distributionRoot = join(resolveRunnerProviderAssetsRoot(import.meta.url, "cursor"), key); + return { + distributionRoot, + manifestPath: join(distributionRoot, ".paperclip-cursor-closure.json"), + expectedClosureSha256: CLOSURE_PINS[key as keyof typeof CLOSURE_PINS], + executable: "node", + entrypoint: "index.js", + fixedArguments: [...CURSOR_FIXED_ARGUMENTS], + }; +} + +/** Verifies launch bytes, not qualification. Profile admission remains separate. */ +export async function verifyCursorInstallation(profile: QualifiedAcpxProfile): Promise { + const trusted = QUALIFIED_ACPX_PROFILES.cursor; + const modelFields = new Set(["qualificationModel", "reportedModelId"]); + if (Object.keys(profile).some(key => !Object.hasOwn(trusted, key)) + || Object.entries(trusted).some(([key, value]) => !modelFields.has(key) && profile[key as keyof QualifiedAcpxProfile] !== value) + || !profile.qualificationModel.trim() || profile.reportedModelId !== profile.qualificationModel) { + throw new Error("Cursor installation requires the exact pinned profile and an explicit model"); + } + const installation = await verifyNativeAcpxInstallation(cursorNativeDistributionSpec()); + return { ...installation, commandDigest: trusted.commandDigest }; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-instructions.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-instructions.test.ts new file mode 100644 index 00000000000..9fed89fca70 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-instructions.test.ts @@ -0,0 +1,180 @@ +import { spawn } from "node:child_process"; +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"; + +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 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 }; + +it("binds exact UTF-8 content, including explicit empty instructions", () => { + expect(JSON.parse(binding.payload).content).toBe(content); + expect(binding.byteLength).toBe(Buffer.byteLength(content)); + expect(cursorInstructionBinding("").digest).toBe("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + expect(() => cursorInstructionBinding("x\0y")).toThrow(/NUL/); + expect(() => cursorInstructionBinding("é".repeat(16385))).toThrow(/bounded/); +}); + +it("refreshes trusted sandbox instructions on recovery and excludes ambient overrides", async () => { + const dir = await mkdtemp(join(tmpdir(), "cursor-instruction-sandbox-")); + try { + const workspace = join(dir, "workspace"); const runtimeDirectory = join(dir, "runtime"); + await Promise.all([mkdir(workspace), mkdir(runtimeDirectory)]); + const recovery = await createAcpxRecoveryBinding({ runtimeDirectory, normalizedSessionId: "cursor-instructions", workingDirectory: workspace, profile: resolveQualifiedAcpxProfile("cursor", "fixture"), requestedModel: "fixture", permissionMode: "approve-reads" }); + for (const text of [content, "updated entry", ""]) { + const sandbox = await prepareAcpxRuntimeSandbox({ binding: recovery, agent: "cursor", environment: { PAPERCLIP_CURSOR_INSTRUCTIONS: "ambient injection" }, providerPolicy: { readOnly: true, systemInstructions: text } }); + expect(sandbox.launchEnvironment.PAPERCLIP_CURSOR_INSTRUCTIONS).toBe(cursorInstructionBinding(text).payload); + expect(sandbox.persistedEnvironment.PAPERCLIP_CURSOR_INSTRUCTIONS).toBeUndefined(); + } + } finally { await rm(dir, { recursive: true, force: true }); } +}); + +it("scopes acknowledgements to their request, session and current connection", () => { + const admission = createCursorInstructionAdmission(content); + const first = admission.createGuard(); + first("inbound", { id: 0, result: { sessionId: "forged", _meta: { paperclipCursorInstructions: ack } } }); + expect(admission.assertReady).toThrow(/did not acknowledge/); + first("outbound", { id: 0, method: "session/new", params: {} }); + first("inbound", { id: 0, result: { sessionId: "s", _meta: { paperclipCursorInstructions: ack } } }); + admission.assertReady(); + first("outbound", { id: 1, method: "session/prompt", params: { sessionId: "s" } }); + const second = admission.createGuard(); + expect(() => first("outbound", { method: "session/prompt", params: { sessionId: "s" } })).toThrow(/replaced/); + expect(() => second("outbound", { method: "session/prompt", params: { sessionId: "s" } })).toThrow(/did not acknowledge/); +}); + +const fixture = String.raw` +const send=value=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\n'); +const ack=JSON.parse(process.env.FIXTURE_ACK);const mode=process.env.FIXTURE_MODE; +const configOptions=[{id:'model',name:'Model',type:'select',currentValue:'fixture',options:[{value:'fixture',name:'Fixture'}]}]; +require('node: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:{configOptions,...(m.method==='session/new'?{sessionId:'cursor-session'}:{}),...(mode==='missing'?{}:{_meta:{paperclipCursorInstructions:mode==='wrong'?{...ack,digest:'sha256:'+'0'.repeat(64)}:ack}})}}); + else if(m.method==='session/set_config_option')send(mode==='unsupported'?{id:m.id,error:{code:-32602,message:'Unsupported exact model'}}:{id:m.id,result:{configOptions}}); + else if(m.method==='session/prompt'){process.stderr.write('PROMPT\n');send({id:m.id,result:{stopReason:'end_turn'}});} +});`; + +it.each(["valid", "missing", "wrong"])("actual SDK reconnect/load %s acknowledgement gates prompt bytes", async mode => { + const dir = await mkdtemp(join(tmpdir(), "cursor-instruction-sdk-")); + const admission = createCursorInstructionAdmission(content); + let prompts = 0; + const open = async (responseMode: string) => { + const client = new AcpClient({ agentCommand: "fixture", cwd: dir, permissionMode: "deny-all", authPolicy: "skip", fs: false, terminal: false, + protocolGuardFactory: () => admission.createGuard(), + spawnAgent: () => { const child = spawn(process.execPath, ["-e", fixture], { cwd: dir, env: { FIXTURE_ACK: JSON.stringify(ack), FIXTURE_MODE: responseMode }, stdio: ["pipe", "pipe", "pipe"] }); child.stderr.on("data", data => { prompts += String(data).split("PROMPT").length - 1; }); return child; }, + }); + await client.start(); return client; + }; + let first; let loaded; + try { + first = await open("valid"); await first.createSession(dir); admission.assertReady(); await first.close(); + loaded = await open(mode); + if (mode === "valid") { + await loaded.loadSessionWithOptions("cursor-session", dir, { suppressReplayUpdates: true, replayIdleMs: 1 }); + admission.assertReady(); await loaded.prompt("cursor-session", "fixture"); + } else { + await expect(loaded.loadSessionWithOptions("cursor-session", dir)).rejects.toThrow(/Cursor instruction admission failed/); + expect(admission.assertReady).toThrow(/acknowledgement/); + await expect(loaded.prompt("cursor-session", "fixture")).rejects.toThrow(); + } + } finally { await loaded?.close().catch(() => {}); await first?.close().catch(() => {}); await rm(dir, { recursive: true, force: true }); } + expect(prompts).toBe(mode === "valid" ? 1 : 0); +}); + +it.each(["valid", "missing", "wrong"])("actual runtime automatically reloads with %s instructions after provider death", async mode => { + const { createAcpRuntime, createAgentRegistry, createRuntimeStore } = await import("acpx/runtime"); + const dir = await mkdtemp(join(tmpdir(), "cursor-instruction-reload-")); + const admission = createCursorInstructionAdmission(content); + const children: ReturnType[] = []; + const writes: string[] = []; + const runtime = createAcpRuntime({ cwd: dir, agentRegistry: createAgentRegistry({ overrides: { cursor: "fixture" } }), sessionStore: createRuntimeStore({ stateDir: join(dir, "state") }), permissionMode: "deny-all", + protocolGuardFactory: () => admission.createGuard(), + onAcpMessage: (direction, value) => { if (direction === "outbound") writes.push((value as { method: string }).method); }, + spawnAgent: () => { const child = spawn(process.execPath, ["-e", fixture], { cwd: dir, env: { FIXTURE_ACK: JSON.stringify(ack), FIXTURE_MODE: children.length === 0 ? "valid" : mode }, stdio: ["pipe", "pipe", "pipe"] }); children.push(child); return child; }, + }); + let handle; + try { + handle = await runtime.ensureSession({ sessionKey: "cursor-instruction-auto-reload", agent: "cursor", mode: "persistent", cwd: dir }); + admission.assertReady(); + const exited = new Promise(resolve => children[0].once("exit", resolve)); + children[0].kill("SIGTERM"); await exited; + const turn = runtime.startTurn({ handle, text: "fixture", mode: "prompt", requestId: "reload-fixture", timeoutMs: 2000 }); + const drain = (async () => { for await (const _event of turn.events) { /* drain bounded fixture events */ } })(); + const result = await turn.result; await drain; + expect(writes).toContain("session/load"); + expect(children.length).toBe(2); + if (mode === "valid") { expect(result.status).toBe("completed"); expect(writes.filter(method => method === "session/prompt")).toHaveLength(1); } + else { expect(result).toMatchObject({ status: "failed", error: { message: expect.stringContaining("Cursor instruction admission failed") } }); expect(writes).not.toContain("session/prompt"); } + } finally { + if (handle) await runtime.close({ handle, reason: "fixture cleanup" }).catch(() => {}); + for (const child of children) child.kill("SIGKILL"); + await rm(dir, { recursive: true, force: true }); + } +}); + + +it.each(["valid", "missing", "wrong", "unsupported"])("fresh runtime manager admits cold saved Cursor session only after %s load/config acknowledgement", async mode => { + const { createAcpRuntime, createAgentRegistry, createRuntimeStore } = await import("acpx/runtime"); + const dir = await mkdtemp(join(tmpdir(), "cursor-cold-admission-")); + const children: ReturnType[] = []; + const writes: Array<{ phase: string; method: string }> = []; + const create = (instructions: string, phase: string, responseMode: string) => { + const admission = createCursorInstructionAdmission(instructions); + const binding = cursorInstructionBinding(instructions); + const currentAck = { schema: "paperclip.cursor.instructions.v1", digest: binding.digest, byteLength: binding.byteLength }; + const runtime = createAcpRuntime({ cwd: dir, agentRegistry: createAgentRegistry({ overrides: { cursor: "fixture" } }), + sessionStore: createRuntimeStore({ stateDir: join(dir, "state") }), permissionMode: "deny-all", + protocolGuardFactory: () => admission.createGuard(), + onAcpMessage: (direction, value) => { if (direction === "outbound") writes.push({ phase, method: (value as { method: string }).method }); }, + spawnAgent: () => { + const child = spawn(process.execPath, ["-e", fixture], { cwd: dir, env: { FIXTURE_ACK: JSON.stringify(currentAck), FIXTURE_MODE: responseMode }, stdio: ["pipe", "pipe", "pipe"] }); + children.push(child); return child; + }, + }); + return { runtime, admission }; + }; + let first: ReturnType | undefined; let second: ReturnType | undefined; + let firstHandle; let secondHandle; let renewed = 0; + const session = { sessionKey: "cursor-cold-record", agent: "cursor", mode: "persistent" as const, cwd: dir }; + try { + first = create(content, "first", "valid"); firstHandle = await first.runtime.ensureSession(session); first.admission.assertReady(); + await first.runtime.close({ handle: firstHandle, reason: "First host retired" }); firstHandle = undefined; + second = create(`${content}\nUpdated registered agent path`, "second", mode); + const before = children.length; + secondHandle = await second.runtime.ensureSession(session); + 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" }), + refreshCommand: async () => { renewed++; }, + }); + if (mode === "valid") { + await admit; expect(renewed).toBe(1); second.admission.assertReady(); + expect(writes.filter(row => row.phase === "second").map(row => row.method)).toEqual(["initialize", "session/load", "session/set_config_option"]); + const turn = second.runtime.startTurn({ handle: secondHandle, text: "fixture", mode: "prompt", requestId: "cold-fixture", timeoutMs: 2000 }); + 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"]); + } 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); + if (mode !== "unsupported") expect(writes.some(row => row.phase === "second" && row.method === "session/set_config_option")).toBe(false); + } + } finally { + if (first && firstHandle) await first.runtime.close({ handle: firstHandle, reason: "fixture cleanup" }).catch(() => {}); + if (second && secondHandle) await second.runtime.close({ handle: secondHandle, reason: "fixture cleanup" }).catch(() => {}); + for (const child of children) if (child.exitCode === null) child.kill("SIGKILL"); + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-instructions.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-instructions.ts new file mode 100644 index 00000000000..84497d44bbb --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-instructions.ts @@ -0,0 +1,87 @@ +import { createHash } from "node:crypto"; + +const SCHEMA = "paperclip.cursor.instructions.v1"; +const MAX_BYTES = 32 * 1024; + +export function cursorInstructionBinding(content: string): { payload: string; digest: string; byteLength: number } { + if (typeof content !== "string" || content.includes("\0") || Buffer.byteLength(content) > MAX_BYTES) { + throw new Error("Cursor instructions exceed their bounded size or contain NUL"); + } + const digest = `sha256:${createHash("sha256").update(content, "utf8").digest("hex")}`; + return { payload: JSON.stringify({ schema: SCHEMA, content, digest }), digest, byteLength: Buffer.byteLength(content) }; +} + +function record(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +/** Each connection must acknowledge its exact instructions before any prompt. */ +export function createCursorInstructionAdmission(content: string) { + const expected = cursorInstructionBinding(content); + let current: { sessionId: string | null; error: Error | null } | null = null; + const failure = (message: string) => new Error(`Cursor instruction admission failed: ${message}`); + const assertReady = () => { + if (current?.error) throw current.error; + if (!current?.sessionId) throw failure("provider did not acknowledge the composed instructions"); + }; + return { + assertReady, + createGuard() { + const state = { sessionId: null as string | null, error: null as Error | null }; + current = state; + const pending = new Map(); + return (direction: "inbound" | "outbound", value: unknown): void => { + if (current !== state) throw failure("connection authority was replaced"); + if (state.error) throw state.error; + const message = record(value); + try { + if (direction === "outbound") { + if (message.method === "session/new" || message.method === "session/load") { + if ((typeof message.id !== "string" && typeof message.id !== "number") || pending.size !== 0) { + throw failure("session admission request is ambiguous"); + } + state.sessionId = null; + pending.set(message.id, { method: message.method, sessionId: record(message.params).sessionId }); + } else if (message.method === "session/prompt") { + assertReady(); + if (record(message.params).sessionId !== state.sessionId) throw failure("prompt belongs to an unacknowledged session"); + } + return; + } + if (message.method !== undefined || (typeof message.id !== "string" && typeof message.id !== "number")) return; + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + if (message.error !== undefined) return; // Preserve the provider's typed session error. + const result = record(message.result); + const ack = record(record(result._meta).paperclipCursorInstructions); + if (ack.schema !== SCHEMA || ack.digest !== expected.digest || ack.byteLength !== expected.byteLength) { + throw failure("provider acknowledgement is missing or does not match the composed instructions"); + } + const sessionId = request.method === "session/new" ? result.sessionId : request.sessionId; + if (typeof sessionId !== "string" || !sessionId.trim()) throw failure("acknowledgement has no session identity"); + state.sessionId = sessionId; + } catch (error) { + state.error = error instanceof Error ? error : failure("invalid acknowledgement"); + throw state.error; + } + }; + }, + }; +} + +/** Cold ACPX ensureSession can return only a disk record. Establish a real load + * before admitting that handle; every later connection still gets its own guard. + */ +export async function admitCursorInstructions( + admission: ReturnType, + input: { providerSpawned: boolean; load: () => Promise; refreshCommand: () => Promise }, +): Promise { + if (!input.providerSpawned) { + await input.load(); + admission.assertReady(); + // ACPX control calls close their temporary native connection. A subsequent + // prompt needs a fresh verified command lease, never a consumed snapshot. + await input.refreshCommand(); + } else admission.assertReady(); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-launch-policy.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-launch-policy.test.ts new file mode 100644 index 00000000000..7c110085d8d --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-launch-policy.test.ts @@ -0,0 +1,25 @@ +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { assertCursorWorkspacePolicy, CURSOR_FIXED_ARGUMENTS, cursorPrivateEnvironment } from "./cursor-launch-policy.js"; +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); +async function fixture() { const root = await mkdtemp(join(tmpdir(), "cursor-policy-")); roots.push(root); await mkdir(join(root, "project")); await mkdir(join(root, ".git")); return root; } +it("uses fixed update/project config flags and memory-only credential storage", () => { + expect(CURSOR_FIXED_ARGUMENTS).toEqual(["--disable-project-configs", "--disable-auto-update", "acp"]); + expect(cursorPrivateEnvironment({ agentHomeDirectory: "/private/home", dataDirectory: "/private/data", cacheDirectory: "/private/cache" })).toEqual({ CURSOR_CONFIG_DIR: "/private/home", CURSOR_DATA_DIR: "/private/data", NODE_COMPILE_CACHE: "/private/cache/cursor-compile-cache", AGENT_CLI_CREDENTIAL_STORE: "memory", NO_OPEN_BROWSER: "1" }); +}); +it("rejects parent-root MCP/hooks and symlinked config, while leaving ordinary Claude settings alone", async () => { + const root = await fixture(); const project = join(root, "project"); + await assertCursorWorkspacePolicy(project); + await mkdir(join(root, ".claude")); await writeFile(join(root, ".claude", "settings.json"), JSON.stringify({ model: "x" })); + await assertCursorWorkspacePolicy(project); + await writeFile(join(root, ".claude", "settings.json"), JSON.stringify({ hooks: {} })); + await expect(assertCursorWorkspacePolicy(project)).rejects.toThrow("ambient execution configuration"); + await rm(join(root, ".claude"), { recursive: true }); await mkdir(join(root, ".cursor")); + await writeFile(join(root, ".cursor", "mcp.json"), "{}"); + await expect(assertCursorWorkspacePolicy(project)).rejects.toThrow("mcp.json"); + await rm(join(root, ".cursor", "mcp.json")); await symlink("missing", join(root, ".cursor", "hooks.json")); + await expect(assertCursorWorkspacePolicy(project)).rejects.toThrow("hooks.json"); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-launch-policy.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-launch-policy.ts new file mode 100644 index 00000000000..68b9866b9f4 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-launch-policy.ts @@ -0,0 +1,63 @@ +import { lstat, readFile, realpath } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +export const CURSOR_PINNED_VERSION = "2026.09.26-dd393fe"; +export const CURSOR_FIXED_ARGUMENTS = ["--disable-project-configs", "--disable-auto-update", "acp"] as const; +export const CURSOR_CREDENTIAL_ENVIRONMENT_NAMES = ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"] as const; + +/** Add only after the shared launcher has created and pinned private roots. */ +export function cursorPrivateEnvironment(paths: { agentHomeDirectory: string; dataDirectory: string; cacheDirectory: string }): NodeJS.ProcessEnv { + return { + CURSOR_CONFIG_DIR: paths.agentHomeDirectory, + CURSOR_DATA_DIR: paths.dataDirectory, + NODE_COMPILE_CACHE: join(paths.cacheDirectory, "cursor-compile-cache"), + AGENT_CLI_CREDENTIAL_STORE: "memory", + NO_OPEN_BROWSER: "1", + }; +} + +/** + * The vendor's disable-project-configs flag covers cli.json only. Keep this + * admission refusal as defense in depth without mutating the user's project. + * The separately verified paperclip-cursor-usage-v4 distribution removes + * ambient MCP and hook discovery at its source for the entire session. This + * check alone is not continuous filesystem protection or an OS sandbox. + */ +export async function assertCursorWorkspacePolicy(workspacePath: string, platform: NodeJS.Platform = process.platform): Promise { + const paths: Array<{ path: string; inspectClaude?: boolean }> = []; + const workspace = await realpath(workspacePath); + let directory = workspace; + const ancestors: string[] = []; + let foundRepository = false; + while (true) { + ancestors.push(directory); + try { await lstat(join(directory, ".git")); foundRepository = true; break; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new Error("Cursor project root could not be inspected"); } + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + for (const directory of foundRepository ? ancestors : [workspace]) { + paths.push( + { path: join(directory, ".cursor", "mcp.json") }, + { path: join(directory, ".cursor", "hooks.json") }, + { path: join(directory, ".cursor", "plugins") }, + { path: join(directory, ".claude", "plugins") }, + { path: join(directory, ".claude", "settings.json"), inspectClaude: true }, + { path: join(directory, ".claude", "settings.local.json"), inspectClaude: true }, + ); + } + paths.push({ path: platform === "darwin" ? "/Library/Application Support/Cursor/hooks.json" : "/etc/cursor/hooks.json" }); + for (const { path, inspectClaude } of paths) { + let metadata; + try { metadata = await lstat(path); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw new Error("Cursor ambient execution configuration could not be inspected"); } + if (inspectClaude && metadata.isFile() && !metadata.isSymbolicLink() && metadata.size <= 64 * 1024) { + try { + const config: unknown = JSON.parse(await readFile(path, "utf8")); + if (config !== null && typeof config === "object" && !Array.isArray(config) && !("hooks" in config) && !("enabledPlugins" in config)) continue; + } catch { /* Unreadable or invalid execution config cannot be admitted. */ } + } + throw new Error(`Cursor ambient execution configuration is not allowed in an isolated runner: ${path}`); + } +} diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-mode.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-mode.test.ts new file mode 100644 index 00000000000..34b7fcb986b --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-mode.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import { createCursorModeAdmission, resolveCursorSessionMode } from "./cursor-mode.js"; + +const config = (mode: string) => [{ id: "mode", type: "select", currentValue: mode, options: ["agent", "plan", "ask"].map(value => ({ value, name: value })) }]; +const session = (mode: string) => ({ sessionId: "native", modes: { currentModeId: mode }, configOptions: config(mode) }); +const prompt = { id: 9, method: "session/prompt", params: { sessionId: "native" } }; +function opened(expected: "agent" | "plan" | "ask", initial = expected) { + const admission = createCursorModeAdmission(expected); const guard = admission.createGuard(); + guard("outbound", { id: 0, method: "session/new", params: {} }); + guard("inbound", { id: 0, result: session(initial) }); + return { admission, guard }; +} + +describe("Cursor native mode admission", () => { + it("defaults only Cursor and rejects unadmitted aliases and other providers", () => { + expect(resolveCursorSessionMode("cursor", undefined)).toBe("agent"); + expect(resolveCursorSessionMode("codex", undefined)).toBeUndefined(); + for (const mode of ["code", "architect", "search", "chat", "", null]) expect(() => resolveCursorSessionMode("cursor", mode)).toThrow(); + expect(() => resolveCursorSessionMode("pi", "agent")).toThrow(/only supported/); + }); + it.each(["agent", "plan", "ask"] as const)("admits %s only after correlated native acknowledgements", expected => { + const { admission, guard } = opened(expected); + admission.assertReady(); guard("outbound", prompt); + const reloaded = admission.createGuard(); + expect(admission.assertReady).toThrow(); + expect(() => guard("outbound", prompt)).toThrow(/replaced/); + reloaded("outbound", { id: 1, method: "session/load", params: { sessionId: "native" } }); + reloaded("inbound", { id: 1, result: session(expected) }); + admission.assertReady(); reloaded("outbound", prompt); + }); + it("permits explicit initial configuration and does not use an update as its acknowledgement", () => { + const { admission, guard } = opened("plan", "agent"); + expect(admission.isReady()).toBe(false); + guard("outbound", { id: 2, method: "session/set_config_option", params: { sessionId: "native", configId: "mode", value: "plan" } }); + guard("inbound", { method: "session/update", params: { sessionId: "native", update: { sessionUpdate: "current_mode_update", currentModeId: "plan" } } }); + expect(admission.isReady()).toBe(false); + guard("inbound", { id: 2, result: { configOptions: config("plan") } }); + admission.assertReady(); guard("outbound", prompt); + }); + it.each(["missing", "conflicting", "wrong"])("gates a reloaded prompt with %s mode acknowledgement", kind => { + const { admission } = opened("plan"); const guard = admission.createGuard(); + guard("outbound", { id: 2, method: "session/load", params: { sessionId: "native" } }); + const result = kind === "missing" ? {} : kind === "conflicting" ? { ...session("plan"), modes: { currentModeId: "ask" } } : session("agent"); + if (kind === "wrong") guard("inbound", { id: 2, result }); + else expect(() => guard("inbound", { id: 2, result })).toThrow(/mode admission/); + expect(() => guard("outbound", prompt)).toThrow(/mode admission/); + }); + it("rejects late mode drift and keeps the connection failed closed", () => { + const { admission, guard } = opened("plan"); + expect(() => guard("inbound", { method: "session/update", params: { sessionId: "native", update: { sessionUpdate: "current_mode_update", currentModeId: "agent" } } })).toThrow(/drifted/); + expect(admission.assertReady).toThrow(/drifted/); + expect(() => guard("outbound", prompt)).toThrow(/drifted/); + }); + it("rejects wrong config echoes, uncorrelated acknowledgements and cross-session control", () => { + const forged = createCursorModeAdmission("plan"); const g = forged.createGuard(); + g("inbound", { id: 2, result: session("plan") }); expect(forged.assertReady).toThrow(); + const { guard } = opened("plan", "agent"); + guard("outbound", { id: 2, method: "session/set_config_option", params: { sessionId: "native", configId: "mode", value: "plan" } }); + expect(() => guard("inbound", { id: 2, result: { configOptions: config("agent") } })).toThrow(/did not apply/); + const other = opened("plan").guard; + expect(() => other("outbound", { id: 3, method: "session/set_config_option", params: { sessionId: "other", configId: "mode", value: "plan" } })).toThrow(/different session/); + }); + it.each([false, true])("rejects config-option mode drift with active prompt=%s", active => { + const { admission, guard } = opened("plan"); + if (active) guard("outbound", prompt); + expect(() => guard("inbound", { method: "session/update", params: { sessionId: "native", update: { sessionUpdate: "config_option_update", configOptions: config("agent") } } })).toThrow(/drifted/); + expect(admission.assertReady).toThrow(/drifted/); + }); + it.each(["foreign", "duplicate", "invalid"])("rejects %s mode configuration notifications", kind => { + const { guard } = opened("plan"); + const options = kind === "duplicate" ? [...config("plan"), ...config("plan")] : config(kind === "invalid" ? "architect" : "plan"); + expect(() => guard("inbound", { method: "session/update", params: { sessionId: kind === "foreign" ? "other" : "native", update: { sessionUpdate: "config_option_update", configOptions: options } } })).toThrow(/mode admission/); + }); + it("ignores model-only partial updates and cannot admit from a mode notification", () => { + const { admission, guard } = opened("plan", "agent"); + for (const options of [[{ id: "model", currentValue: "fixture" }], config("plan")]) { + guard("inbound", { method: "session/update", params: { sessionId: "native", update: { sessionUpdate: "config_option_update", configOptions: options } } }); + expect(admission.isReady()).toBe(false); + } + }); + it("does not mistake a bare set_mode response for configuration proof", () => { + const { admission, guard } = opened("plan", "agent"); + guard("outbound", { id: 4, method: "session/set_mode", params: { sessionId: "native", modeId: "plan" } }); + guard("inbound", { id: 4, result: {} }); expect(admission.assertReady).toThrow(); + }); +}); + +// Real ACPX runtime/SDK transport with a credential-free deterministic peer. +// This proves reconnection ordering and zero prompt bytes on rejection, not +// Cursor model tool selection or a paid provider interaction. +it.each(["plan", "agent", "missing"])("actual runtime auto-load observes %s mode before prompt delivery", async loadedMode => { + const { spawn } = await import("node:child_process"); + const { mkdtemp, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { createAcpRuntime, createAgentRegistry, createRuntimeStore } = await import("acpx/runtime"); + const dir = await mkdtemp(join(tmpdir(), "cursor-mode-runtime-")); + const children: ReturnType[] = []; + const methods: string[] = []; + const admission = createCursorModeAdmission("plan"); + const peer = String.raw` +const send=x=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...x})+'\n'); +let mode=process.env.MODE; +const config=()=>[{id:'mode',name:'Mode',type:'select',currentValue:mode,options:['agent','plan','ask'].map(value=>({value,name:value}))}]; +require('node: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:'native',...(mode==='missing'?{}:{modes:{currentModeId:mode,availableModes:['agent','plan','ask'].map(id=>({id,name:id}))},configOptions:config()})}}); + else if(m.method==='session/prompt')send({id:m.id,result:{stopReason:'end_turn'}}); +});`; + const runtime = createAcpRuntime({ cwd: dir, agentRegistry: createAgentRegistry({ overrides: { cursor: "fixture" } }), sessionStore: createRuntimeStore({ stateDir: join(dir, "state") }), permissionMode: "deny-all", + protocolGuardFactory: () => admission.createGuard(), + onAcpMessage: (direction, value) => { if (direction === "outbound") methods.push((value as { method: string }).method); }, + spawnAgent: () => { const child = spawn(process.execPath, ["-e", peer], { cwd: dir, env: { MODE: children.length ? loadedMode : "plan" }, stdio: ["pipe", "pipe", "pipe"] }); children.push(child); return child; }, + }); + let handle; + try { + handle = await runtime.ensureSession({ sessionKey: "mode-recovery", agent: "cursor", mode: "persistent", cwd: dir }); + admission.assertReady(); + const exited = new Promise(resolve => children[0].once("exit", resolve)); children[0].kill("SIGTERM"); await exited; + const turn = runtime.startTurn({ handle, text: "fixture", mode: "prompt", requestId: "reloaded", timeoutMs: 2000 }); + const drain = (async () => { for await (const _event of turn.events) { /* drain */ } })(); + const result = await turn.result; await drain; + expect(children).toHaveLength(2); expect(methods).toContain("session/load"); + if (loadedMode === "plan") { expect(result.status).toBe("completed"); expect(methods.filter(m => m === "session/prompt")).toHaveLength(1); } + else { expect(result.status).toBe("failed"); expect(methods).not.toContain("session/prompt"); } + } finally { + if (handle) await runtime.close({ handle, reason: "fixture cleanup" }).catch(() => {}); + for (const child of children) if (child.exitCode === null) child.kill("SIGKILL"); + await rm(dir, { recursive: true, force: true }); + } +}); + +it.each(["valid", "unsupported", "wrong-echo"])("actual cold manager requires %s mode control before a prompt", async behavior => { + const { spawn } = await import("node:child_process"); + const { mkdtemp, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { createAcpRuntime, createAgentRegistry, createRuntimeStore } = await import("acpx/runtime"); + const dir = await mkdtemp(join(tmpdir(), "cursor-mode-cold-")); + const children: ReturnType[] = []; const methods: string[] = []; + const peer = String.raw` +const fs=require('node:fs'), path=process.env.STATE; +let mode=fs.existsSync(path)?fs.readFileSync(path,'utf8'):'agent'; +const send=x=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...x})+'\n'); +const configs=()=>[{id:'mode',name:'Mode',type:'select',currentValue:mode,options:['agent','plan','ask'].map(value=>({value,name:value}))},{id:'model',name:'Model',type:'select',currentValue:'fixture',options:[{value:'fixture',name:'Fixture'}]}]; +require('node: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:'native',modes:{currentModeId:mode,availableModes:['agent','plan','ask'].map(id=>({id,name:id}))},configOptions:configs()}}); + else if(m.method==='session/set_config_option'){ + if(m.params.configId==='mode') { + if(process.env.BEHAVIOR==='unsupported')return send({id:m.id,error:{code:-32602,message:'Unsupported native mode'}}); + if(process.env.BEHAVIOR!=='wrong-echo'){mode=m.params.value;fs.writeFileSync(path,mode);} + } + send({id:m.id,result:{configOptions:configs()}}); + } + else if(m.method==='session/prompt')send({id:m.id,result:{stopReason:'end_turn'}}); +});`; + const create = (mode: "agent" | "plan") => { + const admission = createCursorModeAdmission(mode); + const runtime = createAcpRuntime({ cwd: dir, agentRegistry: createAgentRegistry({ overrides: { cursor: "fixture" } }), sessionStore: createRuntimeStore({ stateDir: join(dir, "state") }), permissionMode: "deny-all", + protocolGuardFactory: () => admission.createGuard(), + onAcpMessage: (direction, value) => { if (direction === "outbound") methods.push((value as { method: string }).method); }, + spawnAgent: () => { const child = spawn(process.execPath, ["-e", peer], { cwd: dir, env: { STATE: join(dir, "mode"), BEHAVIOR: behavior }, stdio: ["pipe", "pipe", "pipe"] }); children.push(child); return child; }, + }); + return { runtime, admission }; + }; + const first = create("agent"); let second: ReturnType | undefined; + let firstHandle; let secondHandle; + const options = { sessionKey: "cold-mode", agent: "cursor", mode: "persistent" as const, cwd: dir }; + try { + firstHandle = await first.runtime.ensureSession(options); first.admission.assertReady(); + await first.runtime.close({ handle: firstHandle, reason: "retire first manager" }); firstHandle = undefined; + second = create("plan"); const count = children.length; + secondHandle = await second.runtime.ensureSession(options); expect(children).toHaveLength(count); + await second.runtime.setConfigOption({ handle: secondHandle, key: "model", value: "fixture" }); + expect(second.admission.isReady()).toBe(false); + const selected = second.runtime.setConfigOption({ handle: secondHandle, key: "mode", value: "plan" }); + if (behavior !== "valid") { + await expect(selected).rejects.toThrow(behavior === "unsupported" ? /Unsupported native mode/ : /did not apply/); + expect(methods).not.toContain("session/prompt"); + } else { + await selected; second.admission.assertReady(); + 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); + expect(methods.filter(m => m === "session/load")).toHaveLength(3); + expect(methods.filter(m => m === "session/prompt")).toHaveLength(1); + } + } finally { + if (firstHandle) await first.runtime.close({ handle: firstHandle, reason: "cleanup" }).catch(() => {}); + if (second && secondHandle) await second.runtime.close({ handle: secondHandle, reason: "cleanup" }).catch(() => {}); + for (const child of children) if (child.exitCode === null) child.kill("SIGKILL"); + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-mode.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-mode.ts new file mode 100644 index 00000000000..80b5de0189e --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-mode.ts @@ -0,0 +1,107 @@ +export type CursorSessionMode = "agent" | "plan" | "ask"; + +export function resolveCursorSessionMode(agent: string, mode: unknown): CursorSessionMode | undefined { + if (agent !== "cursor") { + if (mode !== undefined) throw new Error("Cursor session mode is only supported by Cursor"); + return undefined; + } + if (mode === undefined) return "agent"; + if (mode !== "agent" && mode !== "plan" && mode !== "ask") throw new Error("Invalid Cursor session mode"); + return mode; +} + +function object(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +/** Native mode is independent of permissions. Every connection must prove its + * current mode before a prompt; a persisted ACPX preference is not proof. */ +export function createCursorModeAdmission(expected: CursorSessionMode) { + type State = { sessionId: string | null; mode: CursorSessionMode | null; error: Error | null }; + let current: State | null = null; + const failure = (detail: string) => new Error(`Cursor mode admission failed: ${detail}`); + const assertReady = () => { + if (current?.error) throw current.error; + if (!current?.sessionId || current.mode !== expected) throw failure("native mode does not acknowledge the selected mode"); + }; + const readMode = (result: Record, session: boolean): CursorSessionMode => { + const configs = Array.isArray(result.configOptions) ? result.configOptions.filter(value => object(value).id === "mode") : []; + if (configs.length !== 1) throw failure("native mode configuration is missing or ambiguous"); + const mode = object(configs[0]).currentValue; + if (mode !== "agent" && mode !== "plan" && mode !== "ask") throw failure("native mode is unsupported"); + if (session && object(result.modes).currentModeId !== mode) throw failure("native mode acknowledgements conflict"); + return mode; + }; + return { + assertReady, + isReady() { assertNoError(); return current?.sessionId != null && current.mode === expected; }, + createGuard() { + const state: State = { sessionId: null, mode: null, error: null }; + current = state; + const pending = new Map(); + return (direction: "inbound" | "outbound", value: unknown): void => { + if (current !== state) throw failure("connection authority was replaced"); + assertNoError(); + const message = object(value); + const params = object(message.params); + try { + if (direction === "outbound") { + const method = message.method; + if (method === "session/prompt") { + assertReady(); + if (params.sessionId !== state.sessionId) throw failure("prompt belongs to a different session"); + } else if (method === "session/new" || method === "session/load" || method === "session/set_config_option") { + if (typeof message.id !== "string" && typeof message.id !== "number") throw failure("uncorrelated mode request"); + if (pending.has(message.id)) throw failure("duplicate mode request identity"); + if (method === "session/new" || method === "session/load") { + if (pending.size) throw failure("overlapping session admission"); + state.sessionId = null; state.mode = null; + } else { + if (!state.sessionId || params.sessionId !== state.sessionId) throw failure("configuration belongs to a different session"); + if (params.configId === "mode" && params.value !== expected) throw failure("configuration changes the selected mode"); + } + pending.set(message.id, { method, sessionId: params.sessionId, selectsMode: params.configId === "mode" }); + } else if (method === "session/set_mode") { + if (!state.sessionId || params.sessionId !== state.sessionId || params.modeId !== expected) throw failure("mode control changes the selected mode"); + // This method only returns {}; require a later config/session + // acknowledgement rather than treating that empty result as proof. + state.mode = null; + } + return; + } + if (message.method === "session/update" && object(params.update).sessionUpdate === "config_option_update") { + const update = object(params.update); + // Config updates may contain only model options. A mode entry is + // authoritative observation, but never an admission acknowledgement. + if (Array.isArray(update.configOptions) && update.configOptions.some(value => object(value).id === "mode")) { + if (!state.sessionId || params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session"); + if (readMode(update, false) !== expected) throw failure("native mode drifted from the selected mode"); + } + return; + } + if (message.method === "session/update" && object(params.update).sessionUpdate === "current_mode_update") { + if (!state.sessionId || params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session"); + if (object(params.update).currentModeId !== expected) throw failure("native mode drifted from the selected mode"); + return; // Notifications alone never admit a prompt. + } + if (message.method !== undefined || (typeof message.id !== "string" && typeof message.id !== "number")) return; + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + if (message.error !== undefined) { state.mode = null; return; } + const result = object(message.result); + const session = request.method !== "session/set_config_option"; + const sessionId = request.method === "session/new" ? result.sessionId : request.sessionId; + if (typeof sessionId !== "string" || !sessionId.trim()) throw failure("mode acknowledgement has no session identity"); + const mode = readMode(result, session); + if (!session && (request.selectsMode || state.mode === expected) && mode !== expected) throw failure("configuration did not apply the selected mode"); + state.sessionId = sessionId; state.mode = mode; + } catch (error) { + state.error = error instanceof Error ? error : failure("invalid mode acknowledgement"); + throw state.error; + } + }; + }, + }; + function assertNoError() { if (current?.error) throw current.error; } +} 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 new file mode 100644 index 00000000000..57462493056 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-permission-identity.test.ts @@ -0,0 +1,101 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createAcpRuntime, createAgentRegistry, createRuntimeStore, type AcpPermissionRequest } from "acpx/runtime"; +import { expect, it } from "vitest"; +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"; + +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. +// Native permission deliberately omits rawInput, matching pinned Cursor's shape. +const peer = String.raw` +const ids=['native\u0000tool','native\u007ftool','native\u0085tool','tool/1','x'.repeat(161),'safe-tool-1']; +const send=x=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...x})+'\n'); +let promptId, index=0; +function next() { + if(index===ids.length) { send({id:promptId,result:{stopReason:'end_turn'}}); return; } + const tool={method:'session/update',params:{sessionId:'native',update:{sessionUpdate:'tool_call',toolCallId:ids[index],kind:'execute',title:'Run command',status:'pending',rawInput:{command:'printf fixture-'+index}}}}; + const permission={id:index,method:'session/request_permission',params:{sessionId:'native',toolCall:{toolCallId:ids[index],kind:'execute',title:'Run command',status:'pending'},options:[{kind:'reject_once',optionId:'native-denial-'+index,name:'Deny'}]}}; + for(const frame of process.env.ORDER==='tool-first'?[tool,permission]:[permission,tool])send(frame); +} +require('node: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:{},authMethods:[]}}); + else if(m.method==='session/new')send({id:m.id,result:{sessionId:'native'}}); + else if(m.method==='session/prompt'){promptId=m.id;next();} + else if(m.id===index&&!m.method){ + if(m.result?.outcome?.outcome!=='selected'||m.result.outcome.optionId!=='native-denial-'+index){process.exitCode=2;process.stdin.destroy();return;} + index++;next(); + } +});`; + +it.each(["tool-first", "permission-first"])("real ACPX streams Cursor tool origins while native permission callbacks remain held: %s", async order => { + const cwd = await mkdtemp(join(tmpdir(), "cursor-permission-identity-")); + const children: ChildProcess[] = []; + const pending: Array<{ request: AcpPermissionRequest; before: AcpPermissionRequest; release(): void; delivery: Promise | undefined }> = []; + const replies: unknown[] = []; + const fields: Array> = []; + let promptSettled = false; + 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", authPolicy: "skip", + spawnAgent: () => { const child = spawn(process.execPath, ["-e", peer], { cwd, env: { ORDER: order }, stdio: ["pipe", "pipe", "pipe"] }); children.push(child); return child; }, + onAcpMessage: (direction, value) => { + const message = value as { id?: number; result?: { outcome?: unknown } }; + if (direction === "outbound" && message.result?.outcome) replies.push(structuredClone(message)); + }, + onPermissionRequest: async (request, context) => { + const before = structuredClone(request); + 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 }); + 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)); + return decision; + }, + }); + let handle: Awaited> | undefined; + let drain: Promise | undefined; + try { + handle = await runtime.ensureSession({ sessionKey: "identity-fixture", agent: "cursor", mode: "persistent", cwd }); + const turn = runtime.startTurn({ handle, text: "fixture", mode: "prompt", requestId: "turn", timeoutMs: 5_000 }); + void turn.result.then(() => { promptSettled = true; }); + drain = (async () => { for await (const event of turn.events) evidence.tool(event); })(); + for (const [index, rawId] of rawIds.entries()) { + await expect.poll(() => fields.some(row => row.stage === "permission_requested" && row.requestId === `request-${index}`), { timeout: 2_000 }).toBe(true); + expect(promptSettled).toBe(false); + expect(replies).toHaveLength(index); + const held = pending[index]!; + expect(held.request.raw.toolCall.toolCallId).toBe(rawId); + const projectedId = cursorToolIdentity(rawId); + const related = fields.filter(row => row.toolCallId === projectedId); + expect(related.map(row => row.stage)).toEqual(["tool", "permission_requested"]); + expect(related[1]).toMatchObject({ requestId: `request-${index}`, declineOffered: "true", commandSha256: related[0]!.commandSha256 }); + expect(held.request).toEqual(held.before); + held.release(); + await held.delivery; + expect(replies[index]).toEqual({ jsonrpc: "2.0", id: index, result: { outcome: { outcome: "selected", optionId: `native-denial-${index}` } } }); + } + await expect(turn.result).resolves.toMatchObject({ status: "completed" }); + await drain; + expect(new Set(fields.map(row => row.toolCallId)).size).toBe(rawIds.length); + expect(fields.some(row => row.stage === "evidence_incomplete")).toBe(false); + } finally { + pending.forEach(value => value.release()); + if (handle) await runtime.close({ handle, reason: "fixture complete" }); + for (const child of children) if (child.exitCode === null && child.signalCode === null) { + const exited = new Promise(resolve => child.once("exit", resolve)); child.kill("SIGKILL"); await exited; + } + await drain; + await rm(cwd, { recursive: true, force: true }); + } +}, 10_000); diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-plan-tool-identity.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-plan-tool-identity.ts new file mode 100644 index 00000000000..8350bb02978 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-plan-tool-identity.ts @@ -0,0 +1,23 @@ +import { createHash } from "node:crypto"; +import type { AcpxExtensionInput } from "./profile-extensions.js"; + +// Match the sidecar's provider identity boundary before Rust's opaque tool ID. +export function cursorToolIdentity(value: string): string { + if (Buffer.byteLength(value) <= 240 && !/[\u0000-\u001f\u007f-\u009f]/.test(value)) return value; + return `acpx-tool-${createHash("sha256").update("paperclip.acpx.provider-identity.v1\0tool\0").update(value).digest("hex")}`; +} + +/** Only the admitted native Cursor plan extension can attest a parent tool. */ +export function cursorPlanToolIdentity(agent: string | null | undefined, input: AcpxExtensionInput): string | undefined { + if (agent !== "cursor" || input.method !== "cursor/create_plan") return undefined; + const value = input.details?.toolCallId; + if (typeof value !== "string" || !value.trim() || value.length > 1_000) throw new Error("Cursor plan omitted its bounded tool identity"); + return cursorToolIdentity(value); +} + +/** Direct-driver counterpart of Rust acpx_opaque_item_id(..., "tool"). */ +export function cursorToolExecutionId(value: string): string { + const bounded = cursorToolIdentity(value); + if (/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(bounded)) return bounded; + return `acpx-tool-${createHash("sha256").update("paperclip.acpx.opaque-item.v1\0tool\0").update(bounded).digest("hex")}`; +} 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 new file mode 100644 index 00000000000..1566e01cd7d --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-sdk-interactions.test.ts @@ -0,0 +1,76 @@ +import { spawn } from "node:child_process"; +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"; +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); +// 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. +const fixture = String.raw` +const send=value=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\n');let prompt; +require('node: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:{},authMethods:[]}}); + else if(m.method==='session/new')send({id:m.id,result:{sessionId:'cursor-session'}}); + else if(m.method==='session/prompt'){ + prompt=m.id;const plan=m.params.prompt[0].text==='plan'; + send({id:0,method:plan?'cursor/create_plan':'cursor/ask_question',params:plan + ?{toolCallId:'plan',name:'Complete revision',plan:'1. Read\n2. Verify',todos:[],phases:[]} + :{toolCallId:'question',questions:[{id:'native-single',prompt:'Color',options:[{id:'c',label:'Cobalt'},{id:'a',label:'Amber'}]},{id:'native-multi',prompt:'Trees',allowMultiple:true,options:[{id:'cedar',label:'Cedar'},{id:'maple',label:'Maple'}]}]}}); + }else if(m.method==='session/cancel'){send({id:prompt,result:{stopReason:'cancelled'}});prompt=undefined;} + else if(m.id===0&&!m.method&&prompt!==undefined){ + send({method:'session/update',params:{sessionId:'cursor-session',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:JSON.stringify(m)}}}}); + send({id:prompt,result:{stopReason:'end_turn'}});prompt=undefined; + } +});`; +type Context = { requestId: string | number; signal: AbortSignal; responseDelivery?: Promise }; +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, + extensionMethods: ["cursor/ask_question", "cursor/create_plan"], onExtensionRequest: onRequest, + spawnAgent: () => spawn(process.execPath, ["-e", fixture], { cwd, env: { PATH: "/usr/bin:/bin" }, stdio: ["pipe", "pipe", "pipe"] }), + onSessionUpdate: (params: { update: { content?: { type: string; text: string } } }) => { if (params.update.content?.type === "text") received.push(JSON.parse(params.update.content.text)); }, + }); + try { await client.start(); await client.createSession(); await run(client, received); } + finally { await client.close(); await rm(cwd, { recursive: true, force: true }); } +} +it("roundtrips Cursor multi-select and revision-bound plan decisions through the installed ACP SDK", async () => { + const deliveries: Promise[] = []; 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"); + const questionId = request.input.questionSet.questions[0]!.id; + const answers = method === "cursor/ask_question" + ? { "question-1": { selectedOptionIds: ["option-1"] }, "question-2": { selectedOptionIds: ["option-1", "option-2"] } } + : { [questionId]: { selectedOptionIds: ["accept"] } }; + if (method === "cursor/create_plan") { + expect(request.input.questionSet.description).toBe("1. Read\n2. Verify"); + expect(() => request.input.resolve({ action: "submit", response: { schema: "paperclip.question_response.v1", answers: { "plan-stale": { selectedOptionIds: ["accept"] } } } })).toThrow(); + } + 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(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" } } }, + ]); + }); +}); +it("cancels an unanswered Cursor request without delivering a late answer", async () => { + const started = Promise.withResolvers(); const response = Promise.withResolvers(); let signal: AbortSignal | undefined; + await withClient(async (_method, _params, context) => { signal = context.signal; started.resolve(); return response.promise; }, async (client, received) => { + const prompt = client.prompt("cursor-session", "question"); await started.promise; await client.cancel("cursor-session"); await prompt; + expect(signal?.aborted).toBe(true); response.resolve({ outcome: { outcome: "answered", answers: [] } }); + await new Promise(resolve => setImmediate(resolve)); expect(received).toEqual([]); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.test.ts new file mode 100644 index 00000000000..0c208001bc6 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.test.ts @@ -0,0 +1,163 @@ +import { expect, it } from "vitest"; +import { validateAcpxRichEvent } from "./profile-extensions.js"; +import { createCursorToolEvidence } from "./cursor-tool-evidence.js"; +import { cursorToolIdentity } from "./cursor-plan-tool-identity.js"; +import { normalizeAcpxPermission } from "./acp-permission-adapter.js"; + +const initial = { type: "tool_call", tag: "tool_call", toolCallId: "tool", kind: "execute", status: "pending", rawInput: { command: "printf 'secret-canary' > '/fixture/denied.txt'" } }; +const request = { raw: { sessionId: "session", toolCall: { toolCallId: "tool", kind: "execute" } } }; +function setup(emit?: () => void) { + const events: any[] = []; let active = true; let unavailable = false; + const p = createCursorToolEvidence({ sessionId: "session", turnId: "turn", workingDirectory: "/fixture", active: () => active, + emit: event => { validateAcpxRichEvent(event); emit?.(); events.push(event); }, unavailable: () => { unavailable = true; } }); + return { p, events, stop: () => { active = false; }, unavailable: () => unavailable, fields: () => events.map(e => Object.fromEntries(e.payload.details.map((d: any) => [d.name, d.value]))) }; +} +it("correlates omitted permission input with the original command and records only its hash", () => { + const s = setup(); s.p.tool(initial); const delivered = s.p.permission(request, "request", ["accept", "decline", "cancel"]); + expect(s.fields().map(x => x.stage)).toEqual(["tool", "permission_requested"]); + delivered!("reject_once"); delivered!("reject_once"); s.p.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "tool", status: "failed" }); + expect(s.fields().map(x => x.stage)).toEqual(["tool", "permission_requested", "permission_delivered", "tool"]); + expect(new Set(s.fields().map(x => x.commandSha256)).size).toBe(1); + expect(s.fields()[0].commandSha256).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(JSON.stringify(s.events)).not.toContain("secret-canary"); +}); +it("waits for the original iterator event when permission arrives before the queued tool", () => { + const s = setup(); const delivered = s.p.permission(request, "request", ["decline"]); expect(s.events).toHaveLength(0); + s.p.tool(initial); delivered!("reject_once"); expect(s.fields().map(x => x.stage)).toEqual(["tool", "permission_requested", "permission_delivered"]); +}); +it.each(["tool-first", "permission-first"])("correlates opaque C0/C1/DEL native identities in %s order without changing native requests", order => { + const s = setup(); + const rawIds = ["native\u0000tool", "native\u007ftool", "native\u0085tool", "tool/1", "x".repeat(161), "safe-tool-1"]; + const expectedIds = rawIds.map(cursorToolIdentity); + expect(new Set(expectedIds).size).toBe(rawIds.length); + expect(expectedIds.slice(0, 3).every(value => /^acpx-tool-[a-f0-9]{64}$/.test(value))).toBe(true); + expect(expectedIds.at(-1)).toBe("safe-tool-1"); + for (const [index, toolCallId] of rawIds.entries()) { + const native = { sessionId: "session", inferredKind: "execute" as const, raw: { + sessionId: "session", toolCall: { toolCallId, kind: "execute" }, + options: [{ kind: "reject_once" as const, optionId: `native-denial-${index}`, name: "Deny" }], + } }; + const before = structuredClone(native); + const normalized = normalizeAcpxPermission(native, { provider: "cursor" }); + expect(normalized.toolCallId).toBe(expectedIds[index]); + if (order === "tool-first") s.p.tool({ ...initial, toolCallId }); + const delivered = s.p.permission(native, `request-${index}`, normalized.choices.map(choice => choice.key)); + if (order === "permission-first") { + expect(s.fields().filter(row => row.toolCallId === expectedIds[index])).toEqual([]); + s.p.tool({ ...initial, toolCallId }); + } + const rows = () => s.fields().filter(row => row.toolCallId === expectedIds[index]); + expect(rows().map(row => row.stage)).toEqual(["tool", "permission_requested"]); + expect(rows()[1]).toMatchObject({ requestId: `request-${index}`, declineOffered: "true", operation: "execute", commandSha256: rows()[0].commandSha256 }); + const response = normalized.resolve({ action: "decline" }); + expect(response).toEqual({ outcome: "reject_once" }); + delivered!(response.outcome); + expect(rows().map(row => row.stage)).toEqual(["tool", "permission_requested", "permission_delivered"]); + expect(native).toEqual(before); + expect(native.raw.toolCall.toolCallId).toBe(toolCallId); + } + expect(s.unavailable()).toBe(false); + expect(JSON.stringify(s.events)).not.toContain("secret-canary"); + expect(JSON.stringify(s.events)).not.toContain("native\\u0000tool"); +}); + +it("never associates different opaque native IDs through a matching title or command", () => { + const s = setup(); + s.p.tool({ ...initial, toolCallId: "native\u0000tool", title: "same title" }); + const delivered = s.p.permission({ raw: { sessionId: "session", toolCall: { + toolCallId: "native\u007ftool", title: "same title", kind: "execute", rawInput: initial.rawInput, + } } }, "request", ["decline"]); + delivered!("reject_once"); + expect(s.fields().map(row => row.stage)).toEqual(["tool"]); + expect(s.fields()[0].toolCallId).toBe(cursorToolIdentity("native\u0000tool")); +}); +it("cannot invent origin from permission or a terminal delta", () => { + const s = setup(); const delivered = s.p.permission(request, "request", ["decline"]); delivered!("reject_once"); + s.p.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "tool", kind: "execute", status: "failed", rawInput: initial.rawInput }); expect(s.events).toHaveLength(0); +}); +it.each([ + ["foreign-session", "permission_session_mismatch"], ["changed-command", "conflicting_permission_input"], + ["changed-kind", "changed_tool_kind"], ["reused-origin", "reused_tool_origin"], ["duplicate-permission", "ambiguous_permission"], +])("fails qualification closed for %s with a bounded reason", (variant, reason) => { + const s = setup(); s.p.tool(initial); + if (variant === "foreign-session") s.p.permission({ raw: { ...request.raw, sessionId: "foreign" } }, "request", ["decline"]); + if (variant === "changed-command") s.p.permission({ raw: { ...request.raw, toolCall: { ...request.raw.toolCall, rawInput: { command: "different" } } } }, "request", ["decline"]); + if (variant === "changed-kind") s.p.tool({ ...initial, tag: "tool_call_update", kind: "edit" }); + if (variant === "reused-origin") s.p.tool(initial); + if (variant === "duplicate-permission") { s.p.permission(request, "request", ["decline"]); s.p.permission(request, "request2", ["decline"]); } + expect(s.fields().at(-1)).toEqual({ stage: "evidence_incomplete", toolCallId: "unavailable", reason }); expect(s.unavailable()).toBe(true); + const count = s.events.length; + s.p.tool({ ...initial, tag: "tool_call_update", status: "completed" }); + expect(s.p.permission(request, "later-request", ["decline"])).toBeUndefined(); + expect(s.events).toHaveLength(count); +}); +it("ignores inactive turns and never lets observation errors undo a delivered decision", () => { + const s = setup(); s.p.tool(initial); const delivered = s.p.permission(request, "request", ["decline"]); s.stop(); delivered!("reject_once"); expect(s.events).toHaveLength(2); + let fail = false; const broken = setup(() => { if (fail) throw new Error("secret-canary"); }); broken.p.tool(initial); + const callback = broken.p.permission(request, "request", ["decline"]); fail = true; + expect(() => callback!("reject_once")).not.toThrow(); expect(broken.unavailable()).toBe(true); +}); +it("bounds retained tool origins", () => { + const s = setup(); for (let i = 0; i < 257; i++) s.p.tool({ ...initial, toolCallId: `tool-${i}` }); + expect(s.fields().at(-1).stage).toBe("evidence_incomplete"); expect(s.events).toHaveLength(257); +}); + +it("rejects commands missing from their original frame and bounded oversized input", () => { + for (const command of [undefined, "x".repeat(64 * 1024 + 1)]) { + const s = setup(); s.p.tool({ ...initial, rawInput: { command } }); + expect(s.fields().at(-1)).toEqual({ stage: "evidence_incomplete", toolCallId: "unavailable", reason: "missing_command_origin" }); + } +}); + +it.each([ + ["invalid_permission_tool_identity", { raw: { ...request.raw, toolCall: { ...request.raw.toolCall, toolCallId: "" } } }, "request"], + ["invalid_request_identity", request, "private-request\ncanary"], + ["invalid_permission_kind", { raw: { ...request.raw, toolCall: { ...request.raw.toolCall, kind: "private-kind\ncanary" } } }, "request"], +] as const)("distinguishes %s without retaining malformed provider input", (reason, nativeRequest, requestId) => { + const s = setup(); + expect(s.p.permission(nativeRequest, requestId, ["decline"])).toBeUndefined(); + expect(s.fields()).toEqual([{ stage: "evidence_incomplete", toolCallId: "unavailable", reason }]); + expect(JSON.stringify(s.events)).not.toContain("canary"); +}); + +it("never trusts an external error's message, reason, or cause as a diagnostic code", () => { + let fail = true; + const s = setup(() => { + if (!fail) return; + fail = false; + throw Object.assign(new Error("private-argument-canary", { cause: new Error("private-cause-canary") }), { reason: "permission_session_mismatch" }); + }); + s.p.tool(initial); + expect(s.fields()).toEqual([{ stage: "evidence_incomplete", toolCallId: "unavailable", reason: "projection_failed" }]); + expect(JSON.stringify(s.events)).not.toMatch(/canary|permission_session_mismatch/); + expect(s.unavailable()).toBe(true); +}); + +it("preserves execute denial evidence after valid edit and read permission inputs", () => { + const s = setup(); + for (const kind of ["edit", "read"]) { + const call = { ...initial, toolCallId: kind, kind, rawInput: { path: "/fixture/source.txt", content: "non-command-canary" } }; + s.p.tool(call); + const delivered = s.p.permission({ raw: { sessionId: "session", toolCall: call } }, `${kind}-request`, ["accept", "decline"]); + expect(delivered).toBeTypeOf("function"); delivered!("allow_once"); + s.p.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: kind, status: "completed" }); + } + s.p.tool(initial); s.p.permission(request, "request", ["decline"])!("reject_once"); + s.p.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "tool", status: "failed" }); + expect(s.unavailable()).toBe(false); + expect(s.fields().some(row => row.stage === "evidence_incomplete")).toBe(false); + expect(s.fields().filter(row => row.toolCallId !== "tool").every(row => row.commandSha256 === undefined)).toBe(true); + expect(s.fields().filter(row => row.toolCallId === "read").every(row => row.operation === "read")).toBe(true); + expect(s.fields().filter(row => row.toolCallId === "edit").every(row => row.operation === undefined)).toBe(true); + const shell = s.fields().filter(row => row.toolCallId === "tool"); + expect(shell.map(row => row.stage)).toEqual(["tool", "permission_requested", "permission_delivered", "tool"]); + expect(shell[2].outcome).toBe("reject_once"); expect(shell[3].status).toBe("failed"); + expect(new Set(shell.map(row => row.commandSha256)).size).toBe(1); + expect(shell[0].commandSha256).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(JSON.stringify(s.events)).not.toContain("non-command-canary"); +}); +it.each(["edit", "read", "unknown-native-kind"])("still rejects a contradictory permission kind %s", kind => { + const s = setup(); s.p.tool(initial); + s.p.permission({ raw: { sessionId: "session", toolCall: { toolCallId: "tool", kind, rawInput: { path: "/fixture/file" } } } }, "request", ["decline"]); + expect(s.fields().at(-1).stage).toBe("evidence_incomplete"); expect(s.unavailable()).toBe(true); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.ts new file mode 100644 index 00000000000..1a4eedd6176 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.ts @@ -0,0 +1,126 @@ +import { updateSingleReadEvidence, type SingleReadEvidence } from "./single-read-evidence.js"; +import { createHash } from "node:crypto"; +import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js"; +import type { CanonicalProviderEvent } from "../../provider-events.js"; +import { cursorToolIdentity } from "./cursor-plan-tool-identity.js"; + +const rec = (v: unknown): Record => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record : {}; +const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v); +// ACP tool identities are opaque provider strings. Apply the existing bounded +// tool identity transform rather than rejecting IDs accepted by the ACP SDK. +const nativeToolId = (v: unknown): string | undefined => typeof v === "string" && v.length > 0 && v.length <= 240 ? cursorToolIdentity(v) : undefined; +const digest = (s: string) => createHash("sha256").update(s).digest("hex"); +type Fields = Record; +type Tool = { kind?: string; commandSha256?: string; read?: SingleReadEvidence }; +type Permission = { requestId: string; kind?: string; hasInput: boolean; commandSha256?: string; declineOffered: boolean; requested?: boolean; outcome?: string; delivered?: boolean }; +type ProjectionFailureReason = + | "evidence_limit" | "tool_limit" | "missing_command_origin" | "reused_tool_origin" + | "invalid_tool_kind" | "changed_tool_kind" | "changed_or_missing_command" + | "conflicting_permission_kind" | "conflicting_permission_input" + | "permission_session_mismatch" | "invalid_permission_tool_identity" | "invalid_request_identity" + | "ambiguous_permission" | "invalid_permission_kind" | "unknown_outcome"; +// Only locally constructed failures may publish a reason. Provider or sink +// exception messages, causes, and similarly named fields are never evidence. +class ProjectionFailure extends Error { + constructor(readonly reason: ProjectionFailureReason) { super(reason); } +} + +/** Passive, bounded evidence from the active prompt iterator only. Cursor's + * permission frame omits rawInput; only the same tool's original tool_call may + * supply its command. Never infer a command from a title or a terminal delta. */ +export function createCursorToolEvidence(binding: { + sessionId: string; turnId: string; workingDirectory: string; active(): boolean; + emit(event: CanonicalProviderEvent): void; unavailable?(): void; +}) { + const tools = new Map(); const permissions = new Map(); + let sequence = 0; let broken = false; + function notice(stage: string, toolCallId: string, fields: Fields, method = "session/update") { + if (!binding.active() || !id(binding.sessionId) || !id(binding.turnId)) return; + if (++sequence > 2048 && stage !== "evidence_incomplete") throw new ProjectionFailure("evidence_limit"); + const itemId = `cursor-evidence-${digest(`${binding.sessionId}:${binding.turnId}`).slice(0, 24)}-${sequence}`; + binding.emit({ eventType: "provider.notice.recorded", itemId, payload: redactPaperclipSemanticValue({ + schema: "paperclip.provider.notice.v1", noticeId: itemId, severity: "info", category: "cursor_tool_evidence_v1", scope: "turn", recoverable: true, userActionable: false, + summary: stage === "evidence_incomplete" ? "Some Cursor activity details are unavailable." : stage === "permission_delivered" ? "Cursor received your permission decision." : "Cursor native tool activity recorded.", + provenance: { method, eventType: stage, sessionId: binding.sessionId, turnId: binding.turnId }, + details: Object.entries({ stage, toolCallId, ...fields }).map(([name, value]) => ({ name, value: String(value) })), + }) as Record }); + } + function safely(action: () => T): T | undefined { + if (broken) return; + try { return action(); } catch (error) { + broken = true; + // Reporting failures cannot rewrite a response already delivered to ACP. + const reason = error instanceof ProjectionFailure ? error.reason : "projection_failed"; + try { notice("evidence_incomplete", "unavailable", { reason }); } catch { /* Sink unavailable. */ } + try { binding.unavailable?.(); } catch { /* Diagnostics remain passive. */ } + } + } + function command(call: Record): string | undefined { + const value = rec(call.rawInput).command; + if (typeof value !== "string" || !value || value.includes("\0") || Buffer.byteLength(value) > 64 * 1024) return; + return `sha256:${digest(value)}`; + } + function flush(toolId: string) { + const state = tools.get(toolId), permission = permissions.get(toolId); + if (!state || !permission) return; + if (permission.kind !== undefined && permission.kind !== state.kind) throw new ProjectionFailure("conflicting_permission_kind"); + // Other native tools have non-command input (for example path/content). + // Their bounded identity/status evidence must not disable later shell proof. + if (state.kind === "execute" && permission.hasInput && (!permission.commandSha256 || permission.commandSha256 !== state.commandSha256)) throw new ProjectionFailure("conflicting_permission_input"); + const fields: Fields = { requestId: permission.requestId, declineOffered: permission.declineOffered }; + if (state.kind === "execute" || state.kind === "read") fields.operation = state.kind; + if (state.commandSha256) fields.commandSha256 = state.commandSha256; + if (!permission.requested) { notice("permission_requested", toolId, fields, "session/request_permission"); permission.requested = true; } + if (permission.outcome && !permission.delivered) { notice("permission_delivered", toolId, { ...fields, outcome: permission.outcome }, "session/request_permission"); permission.delivered = true; } + } + return { + tool(event: unknown) { safely(() => { + if (!binding.active()) return; + const call = rec(event); if (call.type !== "tool_call") return; + const toolId = nativeToolId(call.toolCallId); if (toolId === undefined) return; + let state = tools.get(toolId); + if (!state) { + if (call.tag !== "tool_call") return; + if (tools.size >= 256) throw new ProjectionFailure("tool_limit"); + state = {}; tools.set(toolId, state); + if (call.kind === "execute" && !command(call)) throw new ProjectionFailure("missing_command_origin"); + } else if (call.tag === "tool_call") throw new ProjectionFailure("reused_tool_origin"); + if (call.kind !== undefined && (!id(call.kind) || call.kind.length > 64)) throw new ProjectionFailure("invalid_tool_kind"); + if (typeof call.kind === "string") { + if (state.kind && state.kind !== call.kind) throw new ProjectionFailure("changed_tool_kind"); + state.kind = call.kind; + } + if (state.kind === "read") state.read = updateSingleReadEvidence(state.read, call, binding.workingDirectory); + if (call.rawInput !== undefined && state.kind === "execute") { + const hash = command(call); + if (!hash || (call.tag !== "tool_call" && !state.commandSha256) || (state.commandSha256 && state.commandSha256 !== hash)) throw new ProjectionFailure("changed_or_missing_command"); + state.commandSha256 = hash; + } + if (!["pending", "in_progress", "completed", "failed"].includes(String(call.status))) return; + // Publish queued permission delivery before its terminal tool result. + if (call.tag !== "tool_call") flush(toolId); + notice("tool", toolId, { status: String(call.status), ...(["execute", "read"].includes(state.kind ?? "") ? { operation: state.kind! } : {}), ...(state.commandSha256 ? { commandSha256: state.commandSha256 } : {}), ...(state.read?.targetSha256 ? { readTargetSha256: state.read.targetSha256 } : {}) }); + flush(toolId); + }); }, + permission(request: unknown, requestId: string, offeredActions: readonly string[]) { + return safely(() => { + if (!binding.active()) return; + const raw = rec(rec(request).raw), call = rec(raw.toolCall); + if (raw.sessionId !== binding.sessionId) throw new ProjectionFailure("permission_session_mismatch"); + const toolId = nativeToolId(call.toolCallId); + if (toolId === undefined) throw new ProjectionFailure("invalid_permission_tool_identity"); + if (!id(requestId)) throw new ProjectionFailure("invalid_request_identity"); + if (permissions.has(toolId) || permissions.size >= 256) throw new ProjectionFailure("ambiguous_permission"); + if (call.kind !== undefined && (!id(call.kind) || call.kind.length > 64)) throw new ProjectionFailure("invalid_permission_kind"); + const state: Permission = { requestId, kind: call.kind as string | undefined, hasInput: call.rawInput !== undefined, commandSha256: command(call), declineOffered: offeredActions.includes("decline") }; + permissions.set(toolId, state); flush(toolId); + return (outcome: string) => { safely(() => { + if (state.outcome) return; + if (!["allow_once", "allow_always", "reject_once", "cancel"].includes(outcome)) throw new ProjectionFailure("unknown_outcome"); + state.outcome = outcome; flush(toolId); + }); }; + }); + }, + }; +} +export type CursorToolEvidence = ReturnType; diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts b/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts index 070088aab86..bf87e73f70f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts @@ -1,3 +1,4 @@ +import { createCursorProfileExtensionAdapter, CURSOR_CLIENT_CAPABILITIES } from "./cursor-extensions.js"; import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js"; import { parsePaperclipQuestionSet, type PaperclipQuestionSet } from "../../contracts/question-set.js"; import { isCanonicalProviderEventType, type CanonicalProviderEvent } from "../../provider-events.js"; @@ -33,12 +34,14 @@ export interface AcpxProfileExtensionContext { /** Provider branches install their closed, pinned adapters here after qualification research. */ export function createAcpxProfileExtensionAdapter( - _agent: QualifiedAcpxAgent, - _context: AcpxProfileExtensionContext, + agent: QualifiedAcpxAgent, + context: AcpxProfileExtensionContext, ): AcpxProfileExtensionAdapter | null { + if (agent === "cursor") return createCursorProfileExtensionAdapter(context); return null; } -export function acpxProfileClientCapabilities(_agent: QualifiedAcpxAgent): Record { +export function acpxProfileClientCapabilities(agent: QualifiedAcpxAgent): Record { + if (agent === "cursor") return structuredClone(CURSOR_CLIENT_CAPABILITIES); return {}; } diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/profile-installation.test.ts new file mode 100644 index 00000000000..6270a001e79 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/profile-installation.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { assertAcpxProfileEnvironment, classifyAcpxProfileError } from "./profile-installation.js"; + +describe("Cursor actionable admission errors", () => { + it("requires an explicit bound credential and supports both company-secret names", () => { + expect(() => assertAcpxProfileEnvironment("cursor", {})).toThrow("Bind a company secret"); + expect(() => assertAcpxProfileEnvironment("cursor", { CURSOR_AUTH_TOKEN: " " })).toThrow(); + for (const key of ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"]) expect(() => assertAcpxProfileEnvironment("cursor", { [key]: "fixture" })).not.toThrow(); + expect(() => assertAcpxProfileEnvironment("claude", {})).not.toThrow(); + }); + + it.each([ + ["unauthorized: private-value", "CURSOR_AUTHENTICATION_FAILED"], + ["model fixture is unavailable: private-value", "CURSOR_MODEL_UNAVAILABLE"], + ["quota exceeded: private-value", "CURSOR_ENTITLEMENT_UNAVAILABLE"], + ])("reports %s without echoing provider details or authorizing retries", (message, code) => { + const result = classifyAcpxProfileError("cursor", new Error(message)); + expect(result).toMatchObject({ code, retryable: false }); + expect(result?.message).not.toContain("private-value"); + }); + + it("preserves unrelated failures without inventing a provider diagnosis", () => { + expect(classifyAcpxProfileError("cursor", new Error("filesystem is busy"))).toBeNull(); + expect(classifyAcpxProfileError("codex", new Error("unauthorized"))).toBeNull(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts index a78aa47c6c5..a30dbf8a4e3 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts @@ -1,8 +1,17 @@ +import { verifyCursorInstallation } from "./cursor-installation.js"; +import { assertCursorWorkspacePolicy } from "./cursor-launch-policy.js"; import type { QualifiedAcpxAgent, QualifiedAcpxProfile } from "./qualified-profiles.js"; import { verifyQualifiedAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js"; /** Closed build-owned registry. Provider branches add their pinned installations here. */ export async function verifyAcpxProfileInstallation(profile: QualifiedAcpxProfile): Promise { + if (profile.agent === "cursor") { + try { return await verifyCursorInstallation(profile); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") throw Object.assign(new Error("Cursor runtime assets are missing. Run paperclipai runtime setup cursor on this execution host."), { code: "CURSOR_RUNTIME_MISSING", retryable: false }); + throw error; + } + } if (profile.agent !== "claude" && profile.agent !== "codex" && profile.agent !== "grok") { throw new Error(`ACPX ${profile.agent} verified candidate distribution is not installed in this build`); } @@ -10,12 +19,32 @@ export async function verifyAcpxProfileInstallation(profile: QualifiedAcpxProfil } /** Provider policy admission is repeated immediately before each process launch. */ -export async function assertAcpxProfileWorkspace(_agent: QualifiedAcpxAgent, _workspace: string): Promise {} +export async function assertAcpxProfileWorkspace(agent: QualifiedAcpxAgent, workspace: string): Promise { + if (agent === "cursor") await assertCursorWorkspacePolicy(workspace); +} -/** Candidate branches validate only explicitly bound, sanitized launch credentials. */ -export function assertAcpxProfileEnvironment(_agent: QualifiedAcpxAgent, _environment: Readonly): void {} +/** Called with the sanitized launch environment, never ambient process.env. */ +export function assertAcpxProfileEnvironment(agent: QualifiedAcpxAgent, environment: Readonly): void { + if (agent === "cursor" && !environment.CURSOR_API_KEY?.trim() && !environment.CURSOR_AUTH_TOKEN?.trim()) { + throw Object.assign(new Error("Cursor credentials are missing. Bind a company secret to CURSOR_API_KEY or CURSOR_AUTH_TOKEN in the agent environment."), { code: "CURSOR_CREDENTIALS_MISSING", retryable: false }); + } +} -/** Optional provider-specific classification; never changes whether admission succeeded. */ -export function classifyAcpxProfileError(_agent: QualifiedAcpxAgent, _error: unknown): Error | null { - return null; +/** Provider admission diagnostics expose no raw provider strings or credentials. */ +export function classifyAcpxProfileError(agent: QualifiedAcpxAgent, error: unknown): Error | null { + if (agent !== "cursor" || !(error instanceof Error)) return null; + const message = error.message; + let code: string; + let detail: string; + if (/usage limit|quota exceeded|insufficient credits|entitlement|subscription required|upgrade (?:your|the) plan/i.test(message)) { + code = "CURSOR_ENTITLEMENT_UNAVAILABLE"; + detail = "Cursor declined access because of account entitlement or quota. Check the subscription, selected model access, and account spending cap."; + } else if (/invalid (?:api key|auth(?:entication)? token)|authentication failed|unauthenticated|unauthorized|not authenticated/i.test(message)) { + code = "CURSOR_AUTHENTICATION_FAILED"; + detail = "Cursor rejected the bound credential. Update the company secret used by CURSOR_API_KEY or CURSOR_AUTH_TOKEN."; + } else if (/(?:model.*(?:not found|unavailable|unsupported|not available|not allowed)|(?:unknown|invalid) model|effective model mismatch)/i.test(message)) { + code = "CURSOR_MODEL_UNAVAILABLE"; + detail = "Cursor could not use the explicitly selected model. Verify its exact identifier and account access, then select a supported model explicitly."; + } else return null; + return Object.assign(new Error(detail), { code, retryable: false }); } diff --git a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.test.ts b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.test.ts index f6c3cfeb5c3..600ca2111a7 100644 --- a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -26,11 +26,36 @@ it("resolves descriptor sidecars only through the runner-bound canonical package vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(path, "package.json")); expect(resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "pi")).toMatch(/provider-assets\/pi$/); await writeFile(join(path, "package.json"), JSON.stringify({ name: "untrusted" })); - expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "pi")).toThrow("runner package"); + expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "pi")).toThrow("runner or server package"); vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "relative"); expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "pi")).toThrow("normalized absolute"); }); +it("resolves the public server bundle without repository paths or candidate overrides", async () => { + const path = await root(); + const vendored = join(await realpath(path), "dist/vendor/paperclip-runner"); + await mkdir(vendored, { recursive: true }); + await writeFile(join(path, "package.json"), JSON.stringify({ name: "@paperclipai/server" })); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", path); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(path, "package.json")); + expect(resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "cursor")).toBe(join(vendored, "provider-assets/cursor")); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", undefined); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", undefined); + for (const relative of ["drivers/acpx/cursor-installation.js", "cli/acpx-runtime-sidecar.cjs"]) { + expect(resolveRunnerProviderAssetsRoot(pathToFileURL(join(vendored, relative)).href, "cursor")).toBe(join(vendored, "provider-assets/cursor")); + } +}); + +it("rejects a server bundle redirected outside its published package", async () => { + const path = await root(); const outside = await root(); + await mkdir(join(path, "dist/vendor"), { recursive: true }); + await symlink(outside, join(path, "dist/vendor/paperclip-runner")); + await writeFile(join(path, "package.json"), JSON.stringify({ name: "@paperclipai/server" })); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", path); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(path, "package.json")); + expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "cursor")).toThrow("contained"); +}); + it("rejects external manifests, links, asset escapes and incomplete authority", async () => { const path = await root(); const outside = await root(); await writeFile(join(outside, "package.json"), JSON.stringify({ name: "@paperclipai/paperclip-runner" })); diff --git a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts index 326740c00c1..f33ba78f0c1 100644 --- a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts +++ b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts @@ -28,15 +28,23 @@ export function resolveRunnerProviderAssetsRoot(moduleUrl: string, provider: Nat if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs || bytes.length !== Number(before.size) || realpathSync(manifest) !== canonicalManifest) throw new Error("Runner provider manifest changed during admission"); const value = JSON.parse(bytes.toString("utf8")) as { name?: unknown }; - if (value?.name !== RUNNER_PACKAGE_NAME) throw new Error("Runner provider manifest does not name the runner package"); + if (value?.name === "@paperclipai/server") { + // runnerd derives this binding from the verified sidecar in the public + // server package. Runtime setup materializes assets alongside that bundle. + packageRoot = join(dirname(canonicalManifest), "dist/vendor/paperclip-runner"); + if (realpathSync(packageRoot) !== packageRoot) throw new Error("Vendored runner directory is not contained by its server package"); + } else if (value?.name === RUNNER_PACKAGE_NAME) { + packageRoot = dirname(canonicalManifest); + } else throw new Error("Runner provider manifest does not name the runner or server package"); } finally { closeSync(fd); } - packageRoot = dirname(canonicalManifest); } else { if (boundManifest !== undefined) throw new Error("Runner provider manifest has no bound package root"); const url = new URL(moduleUrl); if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Provider factory is outside a verified package layout"); if (new RegExp(`/(?:src|dist)/drivers/acpx/${provider}-installation\\.(?:ts|js)$`).test(url.pathname)) packageRoot = fileURLToPath(new URL("../../../", url)); else if (/\/dist\/cli\/acpx-runtime-sidecar\.(?:cjs|js)$/.test(url.pathname)) packageRoot = fileURLToPath(new URL("../../", url)); + else if (new RegExp(`/dist/vendor/paperclip-runner/drivers/acpx/${provider}-installation\\.(?:js)$`).test(url.pathname)) packageRoot = fileURLToPath(new URL("../../", url)); + else if (/\/dist\/vendor\/paperclip-runner\/cli\/acpx-runtime-sidecar\.(?:cjs|js)$/.test(url.pathname)) packageRoot = fileURLToPath(new URL("../", url)); else throw new Error("Provider factory is outside a verified package layout"); packageRoot = realpathSync(packageRoot); } diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index b7b5f398987..7eeebe2a438 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -2,7 +2,7 @@ export const QUALIFIED_ACPX_VERSION = "0.13.1" as const; export const ACPX_DRIVER_KIND = "acpx_runtime" as const; export const ACPX_DRIVER_PROTOCOL_VERSION = 1 as const; -import type { NativeAcpxAgent } from "../../contracts/native-execution.js"; +import type { NativeAcpxAgent, NativeAcpxProfileSnapshot } from "../../contracts/native-execution.js"; export type QualifiedAcpxAgent = NativeAcpxAgent; @@ -11,7 +11,7 @@ export interface QualifiedAcpxProfile { readonly protocolVersion: typeof ACPX_DRIVER_PROTOCOL_VERSION; readonly acpxVersion: typeof QUALIFIED_ACPX_VERSION; readonly agent: QualifiedAcpxAgent; - readonly agentProfileVersion: 1 | 2 | 3 | 4 | 5; + readonly agentProfileVersion: NativeAcpxProfileSnapshot["agentProfileVersion"]; readonly qualificationStatus?: "pending"; readonly modelPolicy?: "explicit-provider-verified"; /** Wire identity: an npm package name or a runner-owned builtin: identifier. */ @@ -60,10 +60,10 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< }, cursor: { driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, - acpxVersion: QUALIFIED_ACPX_VERSION, agent: "cursor", agentProfileVersion: 2, + acpxVersion: QUALIFIED_ACPX_VERSION, agent: "cursor", agentProfileVersion: 11, agentServerPackage: "cursor-agent", agentServerVersion: "2026.09.26-dd393fe", agentRuntimePackage: null, agentRuntimeVersion: null, - commandDigest: "sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899", + commandDigest: "sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e", // Authenticated discovery has not established a qualification model. Never // turn this empty declaration into a default; callers must select an ID. qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive", diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts index d3cef031776..b6ec96b0d96 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts @@ -1,3 +1,4 @@ +import { resolveCursorSessionMode, type CursorSessionMode } from "./cursor-mode.js"; import { createHash } from "node:crypto"; import { realpath, stat } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; @@ -19,6 +20,7 @@ export interface AcpxRecoveryBinding { requestedModel: string; effectiveModel: string; permissionMode: NativeAcpxPermissionMode; + cursorMode?: CursorSessionMode; profileSessionKey: string; } @@ -33,6 +35,7 @@ export interface AcpxIdentityRecord { requestedModel: string; effectiveModel: string; permissionMode: NativeAcpxPermissionMode; + cursorMode?: CursorSessionMode; providerLifetimeFenceCandidates: readonly [number, number, number]; } @@ -43,9 +46,11 @@ export async function createAcpxRecoveryBinding(input: { profile: QualifiedAcpxProfile; requestedModel: string; permissionMode: NativeAcpxPermissionMode; + cursorMode?: CursorSessionMode; providerPolicy?: { readOnly: boolean; readRoots?: readonly string[]; protectedPaths?: readonly string[] }; }): Promise { validateIdentity(input.normalizedSessionId, "normalized session"); + const cursorMode = resolveCursorSessionMode(input.profile.agent, input.cursorMode); if (input.providerPolicy !== undefined && typeof input.providerPolicy.readOnly !== "boolean") throw new Error("ACPX recovery requires a valid task execution policy"); if (input.requestedModel !== input.profile.qualificationModel) { throw new Error("ACPX recovery requested an unqualified model"); @@ -89,6 +94,7 @@ export async function createAcpxRecoveryBinding(input: { requestedModel: input.requestedModel, profileDigest, permissionMode: input.permissionMode, + ...(cursorMode ? { cursorMode } : {}), }), ).replace("sha256:", "paperclip-"); return { @@ -101,6 +107,7 @@ export async function createAcpxRecoveryBinding(input: { requestedModel: input.requestedModel, effectiveModel: input.requestedModel, permissionMode: input.permissionMode, + ...(cursorMode ? { cursorMode } : {}), profileSessionKey, }; } @@ -121,6 +128,7 @@ export function createAcpxIdentityRecord( requestedModel: binding.requestedModel, effectiveModel: binding.effectiveModel, permissionMode: binding.permissionMode, + ...(binding.cursorMode ? { cursorMode: binding.cursorMode } : {}), providerLifetimeFenceCandidates: Object.freeze([ ...expected.providerLifetimeFenceCandidates, ]) as readonly [number, number, number], @@ -146,6 +154,7 @@ export function acpxProviderSessionIdentity( requestedModel: record.requestedModel, effectiveModel: record.effectiveModel, permissionMode: record.permissionMode, + ...(record.cursorMode ? { cursorMode: record.cursorMode } : {}), providerLifetimeFenceCandidates: record.providerLifetimeFenceCandidates, }; verifyExpectedAcpxIdentity(identity, binding, record); @@ -170,7 +179,8 @@ export function verifyExpectedAcpxIdentity( expected.workspaceDigest !== binding.workspaceDigest || expected.requestedModel !== binding.requestedModel || expected.effectiveModel !== binding.effectiveModel || - expected.permissionMode !== binding.permissionMode + expected.permissionMode !== binding.permissionMode || + expected.cursorMode !== binding.cursorMode ) { throw new Error( "ACPX recovery identity conflicts with the immutable session configuration", @@ -189,6 +199,7 @@ export function verifyExpectedAcpxIdentity( record.requestedModel !== binding.requestedModel || record.effectiveModel !== binding.effectiveModel || record.permissionMode !== binding.permissionMode || + record.cursorMode !== binding.cursorMode || !sameFenceCandidates( record.providerLifetimeFenceCandidates, expected.providerLifetimeFenceCandidates, @@ -213,6 +224,7 @@ function parsePersistedRecord(value: unknown): AcpxIdentityRecord { "requestedModel", "effectiveModel", "permissionMode", + "cursorMode", "providerLifetimeFenceCandidates", ]); return validatedRecord(record); @@ -239,6 +251,7 @@ function validatedRecord(value: Record): AcpxIdentityRecord { if (!isPermissionMode(value.permissionMode)) { throw new Error("ACPX identity permission mode is invalid"); } + if (value.cursorMode !== undefined) resolveCursorSessionMode("cursor", value.cursorMode); validateFenceCandidates(value.providerLifetimeFenceCandidates); return value as unknown as AcpxIdentityRecord; } @@ -267,6 +280,7 @@ function validateExpected(expected: AcpxExpectedSessionIdentity): void { ) { throw new Error("Expected ACPX permission mode is invalid"); } + if (expected.cursorMode !== undefined) resolveCursorSessionMode("cursor", expected.cursorMode); validateFenceCandidates(expected.providerLifetimeFenceCandidates); } diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index 3a2dd3ece64..5ffb48ce5ae 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -195,6 +195,31 @@ afterEach(async () => { }); describe("ACPX runtime host", () => { + it.each([undefined, "plan", "ask"] as const)("requires observed Cursor mode %s in the host identity", async selected => { + const fixture = await hostFixture(); + const mode = selected ?? "agent"; + const options = { ...fixture.options, agent: "cursor" as const, model: "explicit-test-model", cursorMode: selected, permissionMode: "approve-all" as const, environment: { CURSOR_API_KEY: "test" } }; + const openRuntime = vi.fn(async (launch: AcpxRuntimePortOpenOptions) => { + expect(launch.cursorMode).toBe(mode); + return runtimePort({ getStatus: async () => ({ models: { currentModelId: options.model } }), identity: async () => ({ acpxRecordId: "record-1", backendSessionId: "backend-1", agentSessionId: "agent-1", cursorMode: mode }) }); + }); + const host = await AcpxRuntimeHost.open(options, fixture.dependencies({ openRuntime })); + expect(host.identity().cursorMode).toBe(mode); + await host.close({ reason: "mode test complete" }); + }); + + it("rejects missing observed Cursor mode and mode supplied to another provider", async () => { + const fixture = await hostFixture(); + const port = runtimePort({ getStatus: async () => ({ models: { currentModelId: "explicit-test-model" } }) }); + const openRuntime = vi.fn(async () => port); + const options = { ...fixture.options, agent: "cursor" as const, model: "explicit-test-model", permissionMode: "approve-all" as const, environment: { CURSOR_API_KEY: "test" } }; + await expect(AcpxRuntimeHost.open(options, fixture.dependencies({ openRuntime }))).rejects.toThrow(/Cursor mode does not match/); + expect(port.close).toHaveBeenCalled(); + openRuntime.mockClear(); + await expect(AcpxRuntimeHost.open({ ...options, agent: "codex", model: "gpt-5.6-sol", cursorMode: "plan" }, fixture.dependencies({ openRuntime }))).rejects.toThrow(/only supported/); + expect(openRuntime).not.toHaveBeenCalled(); + }); + it.each([ { PAPERCLIP_NATIVE_MCP_NAME: "paperclip-assigned" }, { PAPERCLIP_NATIVE_MCP_NAME: "paperclip", PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1:3211/mcp", PAPERCLIP_NATIVE_MCP_TOKEN: "x".repeat(40) }, @@ -1313,7 +1338,7 @@ describe("ACPX runtime host", () => { onExtensionRequest, onExtensionNotification, }), - ).toBe(turn); + ).toMatchObject({ requestId: turn.requestId, result: turn.result }); expect(startTurn).toHaveBeenCalledWith({ text: "Complete the task.", requestId: "turn-1", @@ -1329,7 +1354,7 @@ describe("ACPX runtime host", () => { expect(turn.cancel).toHaveBeenCalledWith({ reason: "user interrupt" }); await host.close({ reason: "shutdown" }); - expect(turn.cancel).toHaveBeenCalledWith({ reason: "shutdown" }); + expect(turn.cancel.mock.calls.every(([intent]) => intent.reason === "user interrupt")).toBe(true); expect(runtime.close).toHaveBeenCalledOnce(); expect(() => host.startTurn({ text: "Late", requestId: "turn-3" })).toThrow( "is closing", @@ -1796,6 +1821,7 @@ describe("ACPX runtime host", () => { function runtimePort( input: { + identity?: AcpxRuntimePort["identity"]; getStatus?: AcpxRuntimePort["getStatus"]; setModel?: NonNullable; startTurn?: AcpxRuntimePort["startTurn"]; @@ -1803,11 +1829,11 @@ function runtimePort( } = {}, ): AcpxRuntimePort & { close: ReturnType } { return { - identity: async () => ({ + identity: input.identity ?? (async () => ({ acpxRecordId: "record-1", backendSessionId: "backend-1", agentSessionId: "agent-1", - }), + })), getStatus: input.getStatus ?? (async () => ({ diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 1f2d83919c2..937d0815b98 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -1,4 +1,7 @@ +import { withAcpxTurnCancellation } from "./turn-cancellation.js"; +import { resolveCursorSessionMode, type CursorSessionMode } from "./cursor-mode.js"; import { dirname, join } from "node:path"; +import { bindAcpxAgentFiles } from "./agent-files-binding.js"; import { assertAcpxProfileEnvironment, assertAcpxProfileWorkspace, classifyAcpxProfileError, verifyAcpxProfileInstallation } from "./profile-installation.js"; import { createAcpxRuntimeSkillLease } from "./runtime-skill-lease.js"; import { stageManagedGrokCredential } from "./grok-credentials.js"; @@ -75,6 +78,7 @@ const ACPX_ADMISSION_CLEANUP_RETRY_DELAY_MS = 10; const ACPX_ADMISSION_CLEANUP_RESCHEDULE_MS = 1_000; export interface AcpxRuntimePortIdentity { + cursorMode?: CursorSessionMode; acpxRecordId: string; backendSessionId: string; agentSessionId: string; @@ -151,6 +155,7 @@ export interface AcpxRuntimePortOpenOptions { stateDirectory: string; providerSessionKey: string; permissionMode: NativeAcpxPermissionMode; + cursorMode?: CursorSessionMode; permissionPolicy: ReturnType; launchEnvironment: Readonly; /** Kernel credential-home quorum inherited by the provider sentinel. */ @@ -224,6 +229,7 @@ export interface OpenAcpxRuntimeHostOptions { agent: QualifiedAcpxAgent; model: string; permissionMode: NativeAcpxPermissionMode; + cursorMode?: CursorSessionMode; systemInstructions?: string; runtimeContext?: NativeRuntimeContextSnapshot | null; environment?: NodeJS.ProcessEnv; @@ -278,7 +284,9 @@ export class AcpxRuntimeHost { readonly #command: VerifiedAcpxCommandLease; readonly #toolBridge: RunnerToolBridge | null; readonly #claudeSkillNames: readonly string[]; + readonly #assertAgentFilesHeld: (() => void) | undefined; #activeTurn: AcpxRuntimeTurn | null = null; + #activeTurnInterruption: AcpxRuntimeTurn["cancel"] | null = null; #closingStarted = false; #closePromise: Promise | null = null; #closed = false; @@ -292,6 +300,7 @@ export class AcpxRuntimeHost { command: VerifiedAcpxCommandLease; toolBridge: RunnerToolBridge | null; claudeSkillNames: readonly string[]; + assertAgentFilesHeld?: () => void; }) { this.#runtime = input.runtime; this.#binding = input.binding; @@ -301,6 +310,7 @@ export class AcpxRuntimeHost { this.#command = input.command; this.#toolBridge = input.toolBridge; this.#claudeSkillNames = [...input.claudeSkillNames]; + this.#assertAgentFilesHeld = input.assertAgentFilesHeld; } static async open( @@ -329,6 +339,7 @@ export class AcpxRuntimeHost { profile, requestedModel: options.model, permissionMode: options.permissionMode, + cursorMode: resolveCursorSessionMode(options.agent, options.cursorMode), ...(["cursor", "copilot", "pi"].includes(options.agent) && options.providerPolicy !== undefined ? { providerPolicy: options.providerPolicy } : {}), }), @@ -458,6 +469,18 @@ export class AcpxRuntimeHost { } assertAcpxProfileEnvironment(options.agent, sandbox.launchEnvironment); let launchEnvironment = sandbox.launchEnvironment; + // This copy is registered by the authenticated controller for its company, + // agent and run. Environment/config values cannot widen the grant. Each + // resumed process receives the newly registered copy; collection already + // requires verified provider shutdown in the native executor. + const agentFiles = options.agent === "cursor" + ? bindAcpxAgentFiles(options.runtimeContext, [sandbox.root, + ...(installation.agentServerPackageJsonPath === null ? [] : [dirname(installation.agentServerPackageJsonPath)]), + ]) : null; + if (agentFiles) { + launchEnvironment = Object.freeze({ ...launchEnvironment, AGENT_HOME: agentFiles.root, + }); + } if (options.agent === "pi") { const skills = await acquireAbortableAdmissionResource({ signal: options.signal, @@ -476,7 +499,7 @@ export class AcpxRuntimeHost { await skills.close(); }, }; - launchEnvironment = Object.freeze({ ...sandbox.launchEnvironment, + launchEnvironment = Object.freeze({ ...launchEnvironment, PAPERCLIP_PI_READ_ROOTS: JSON.stringify([...(options.providerPolicy?.readRoots ?? []), ...skills.readRoots]), }); } @@ -523,6 +546,7 @@ export class AcpxRuntimeHost { signal: options.signal, acquire: async () => { options.assertWorkspaceHeld?.(); + agentFiles?.assertHeld(); await assertAcpxProfileWorkspace(options.agent, binding.workspacePath); return dependencies.openRuntime({ ...(options.clientCapabilities === undefined ? {} : { @@ -535,6 +559,7 @@ export class AcpxRuntimeHost { stateDirectory: sandbox.stateDirectory, providerSessionKey: binding.profileSessionKey, permissionMode: binding.permissionMode, + cursorMode: binding.cursorMode, permissionPolicy: acpxRuntimePermissionPolicy( binding.permissionMode, ), @@ -543,9 +568,9 @@ export class AcpxRuntimeHost { activateCredentialFenceOwner: admittedLifetime.activateLifetimeOwner.bind(admittedLifetime), systemInstructions: boundedInstructions(options.systemInstructions), - ...(options.assertWorkspaceHeld === undefined + ...(options.assertWorkspaceHeld === undefined && !agentFiles ? {} - : { assertWorkspaceHeld: options.assertWorkspaceHeld }), + : { assertWorkspaceHeld: () => { options.assertWorkspaceHeld?.(); agentFiles?.assertHeld(); } }), ...(options.signal === undefined ? {} : { signal: options.signal }), mcpServers: [ ...(toolBridge ? [{ name: "paperclip", url: toolBridge.url, @@ -592,6 +617,9 @@ export class AcpxRuntimeHost { ), dependencies.retainAdmissionCleanup, ); + if (runtimeIdentity.cursorMode !== binding.cursorMode) { + throw new Error("ACPX runtime Cursor mode does not match the admitted session configuration"); + } const observedIdentity: AcpxExpectedSessionIdentity = { kind: "acpx", normalizedSessionId: binding.normalizedSessionId, @@ -618,6 +646,7 @@ export class AcpxRuntimeHost { credential, command, toolBridge, + assertAgentFilesHeld: agentFiles?.assertHeld, claudeSkillNames: options.agent === "claude" ? (options.runtimeContext?.skills.map((skill) => skill.runtimeName) ?? []) : [], @@ -741,6 +770,7 @@ export class AcpxRuntimeHost { } startTurn(input: AcpxRuntimeTurnInput): AcpxRuntimeTurn { + this.#assertAgentFilesHeld?.(); if (this.#closed || this.#closingStarted) { throw new Error("ACPX runtime host is closing"); } @@ -764,26 +794,30 @@ export class AcpxRuntimeHost { ...(input.onExtensionNotification ? { onExtensionNotification: input.onExtensionNotification } : {}), }); this.#activeTurn = turn; - void turn.result + const managed = withAcpxTurnCancellation(turn, reason => this.close({ reason }), ACPX_TURN_CANCELLATION_SHUTDOWN_BOUND_MS); + this.#activeTurnInterruption = managed.cancel; + void managed.result .finally(() => { // Once shutdown owns this turn, retain its cancellation handle until // runtime cleanup succeeds. The result may settle while cleanup is // failing, and a later close must still be able to retry cancellation. if (this.#activeTurn === turn && !this.#closingStarted) { this.#activeTurn = null; + this.#activeTurnInterruption = null; } }) .catch(() => undefined); - return turn; + return managed; + } + + isClosed(): boolean { + return this.#closed; } async interruptActiveTurn(reason: string): Promise { const turn = this.#activeTurn; if (!turn) throw new Error("ACPX runtime host has no active turn"); - const cancellationError = await boundedCancellation( - turn.cancel({ reason: boundedReason(reason) }), - ); - if (cancellationError) throw cancellationError; + await this.#activeTurnInterruption!({ reason: boundedReason(reason) }); } async close(input: { reason: string }): Promise { diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 8ecdbc88ede..40982d396f1 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -28,6 +28,7 @@ import { } from "node:path"; import { createSanitizedAcpxSpawnInput } from "./environment.js"; +import { cursorInstructionBinding } from "./cursor-instructions.js"; import { claudePaperclipPermissionRules } from "./permission-policy.js"; import type { QualifiedAcpxAgent } from "./qualified-profiles.js"; import { @@ -494,6 +495,7 @@ export async function prepareAcpxRuntimeSandbox(input: { AGENT_CLI_CREDENTIAL_STORE: "memory", NO_OPEN_BROWSER: "1", NODE_DISABLE_COMPILE_CACHE: "1", + PAPERCLIP_CURSOR_INSTRUCTIONS: cursorInstructionBinding(policy.systemInstructions).payload, } : {}), ...(input.agent === "copilot" diff --git a/packages/paperclip-runner/src/drivers/acpx/safe-locations.ts b/packages/paperclip-runner/src/drivers/acpx/safe-locations.ts new file mode 100644 index 00000000000..75897b6eb82 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/safe-locations.ts @@ -0,0 +1,139 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; + +import { classifyGeneratedAcpxToolOperation } from "./generated-sidecar-contract.js"; + +export const ACPX_WORKSPACE_RELATIVE_DISPLAY_BOUNDARY = + "paperclip.workspace_relative_display.v2"; +export const ACPX_WORKSPACE_ENTRY_ATTESTATION = "paperclip.workspace_entry.v1"; +export const ACPX_WORKSPACE_CREATE_TARGET_ATTESTATION = + "paperclip.workspace_create_target.v1"; +const RFC_URI_SCHEME_PREFIX = /^[A-Za-z][A-Za-z0-9+.-]*:/u; +const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u; + +function record(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function permitsCreateTarget(toolKind: unknown, toolTitle: unknown): boolean { + return classifyGeneratedAcpxToolOperation(toolKind, toolTitle) === "edit"; +} + +/** + * Converts provider paths to workspace-relative display targets using the + * sidecar host's path semantics. URI-scheme and Windows drive-shaped values + * are ambiguous on POSIX, so they require a real, in-workspace filesystem + * entry before the sidecar may attest them as filename data. Leading + * backslashes likewise require native-host attestation because they are rooted + * syntax on Windows but valid filename data on POSIX. Consumers must + * treat the result as display data, never as file-access authorization. + */ +export function safeAcpxLocations( + locations: readonly unknown[] | null | undefined, + workingDirectory: string | null | undefined, + toolKind?: unknown, + toolTitle?: unknown, +): Array> { + if (!workingDirectory) return []; + const cwd = resolve(workingDirectory); + let canonicalCwd: string | null | undefined; + return (locations ?? []).slice(0, 2_000).flatMap((location) => { + const candidate = record(location); + const rawPath = typeof candidate.path === "string" ? candidate.path : ""; + if (!rawPath || rawPath.includes("\0")) return []; + const absolute = isAbsolute(rawPath) + ? resolve(rawPath) + : resolve(cwd, rawPath); + const local = relative(cwd, absolute); + if (!local || isAbsolute(local)) return []; + const portable = sep === "\\" ? local.replaceAll("\\", "/") : local; + if ( + portable.startsWith("/") || + portable.split("/").some((segment) => segment === "..") + ) { + return []; + } + // Classify the value we actually emit as well as the provider's spelling. + // Dot-relative and absolute paths can normalize to an ambiguous display + // target even though their raw forms did not begin with an ambiguous shape. + const requiresEntryAttestation = + rawPath.startsWith("\\") || + portable.startsWith("\\") || + WINDOWS_DRIVE_PREFIX.test(rawPath) || + RFC_URI_SCHEME_PREFIX.test(rawPath) || + WINDOWS_DRIVE_PREFIX.test(portable) || + RFC_URI_SCHEME_PREFIX.test(portable); + let pathAttestation: string | undefined; + if (requiresEntryAttestation) { + if (canonicalCwd === undefined) { + try { + canonicalCwd = realpathSync(cwd); + } catch { + canonicalCwd = null; + } + } + if (canonicalCwd === null) return []; + + let entryExists = true; + try { + lstatSync(absolute); + } catch (error) { + entryExists = false; + if ( + !permitsCreateTarget(toolKind, toolTitle) || + !( + error !== null && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) + ) { + return []; + } + } + + if (entryExists) { + try { + const canonicalEntry = realpathSync(absolute); + const entryRelative = relative(canonicalCwd, canonicalEntry); + if ( + !entryRelative || + isAbsolute(entryRelative) || + entryRelative.split(sep).some((segment) => segment === "..") + ) { + return []; + } + pathAttestation = ACPX_WORKSPACE_ENTRY_ATTESTATION; + } catch { + // An existing but unresolved entry includes dangling links. It must + // not be downgraded to a create target by an ENOENT from realpath. + return []; + } + } else { + try { + const canonicalParent = realpathSync(dirname(absolute)); + const parentRelative = relative(canonicalCwd, canonicalParent); + if ( + isAbsolute(parentRelative) || + parentRelative.split(sep).some((segment) => segment === "..") + ) { + return []; + } + pathAttestation = ACPX_WORKSPACE_CREATE_TARGET_ATTESTATION; + } catch { + return []; + } + } + } + return [ + { + path: [...portable].slice(0, 4_000).join(""), + line: candidate.line ?? null, + pathBoundary: ACPX_WORKSPACE_RELATIVE_DISPLAY_BOUNDARY, + ...(pathAttestation ? { pathAttestation } : {}), + }, + ]; + }); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts index 0d2abe1273b..6ff7fa153fd 100644 --- a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts +++ b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts @@ -45,6 +45,7 @@ export interface AcpxSidecarOpenParams { agent: QualifiedAcpxAgent; model: string; permissionMode: NativeAcpxPermissionMode; + cursorMode?: "agent" | "plan" | "ask"; permissionModePinned: boolean; providerPolicy?: { readOnly: boolean }; systemInstructions: string; @@ -65,6 +66,7 @@ export interface AcpxExpectedSessionIdentity { requestedModel: string; effectiveModel: string; permissionMode?: NativeAcpxPermissionMode; + cursorMode?: "agent" | "plan" | "ask"; providerLifetimeFenceCandidates: readonly [number, number, number]; } diff --git a/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts b/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts new file mode 100644 index 00000000000..f9e1a93ff55 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts @@ -0,0 +1,55 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { updateSingleReadEvidence } from "./single-read-evidence.js"; +import { createCursorToolEvidence } from "./cursor-tool-evidence.js"; +import { validateAcpxRichEvent } from "./profile-extensions.js"; +const path = `.paperclip-eval-action-${"a".repeat(36)}.txt`; +const hash = `sha256:${createHash("sha256").update(path).digest("hex")}`; +const origin = { type: "tool_call", tag: "tool_call", toolCallId: "read", kind: "read", status: "pending", rawInput: { path }, locations: [{ path }] }; +it("attests one explicit input path and retains proof through omitted native update fields", () => { + for (const rawInput of [{ path }, { fileName: `/workspace/${path}` }, { path, offset: 0, limit: 10 }]) { + const state = updateSingleReadEvidence(undefined, { ...origin, rawInput }, "/workspace"); expect(state.targetSha256).toBe(hash); + expect(updateSingleReadEvidence(state, { tag: "tool_call_update", status: "completed" }, "/workspace")).toEqual(state); + } +}); +it.each([ + { paths: [path] }, { path, paths: [path, "private.txt"] }, { path, fileName: path }, { path: [path] }, { path, extra: { path: "private.txt" } }, + { path, unknown: "private.txt" }, { path: "../private.txt" }, { path: "https://example.com/file" }, { shellId: "0" }, +])("never attests ambiguous or unsafe input %j", rawInput => { + expect(updateSingleReadEvidence(undefined, { ...origin, rawInput }, "/workspace").targetSha256).toBeUndefined(); + const state = updateSingleReadEvidence(undefined, origin, "/workspace"); + expect(() => updateSingleReadEvidence(state, { ...origin, tag: "tool_call_update", rawInput }, "/workspace")).toThrow(); +}); +it.each([[{ path }, { path }], [{ path }, { path: "../outside" }], [{ path: "other" }], null])("rejects ambiguous/conflicting locations %j", locations => { + expect(updateSingleReadEvidence(undefined, { ...origin, locations }, "/workspace").targetSha256).toBeUndefined(); + const state = updateSingleReadEvidence(undefined, origin, "/workspace"); + expect(() => updateSingleReadEvidence(state, { tag: "tool_call_update", locations }, "/workspace")).toThrow(); +}); +it("does not establish proof from a late update or location/title without scalar input", () => { + expect(updateSingleReadEvidence(undefined, { ...origin, tag: "tool_call_update" }, "/workspace")).toEqual({}); + const state = updateSingleReadEvidence(undefined, { ...origin, rawInput: undefined, title: `Read ${path}` }, "/workspace"); + expect(state).toEqual({}); expect(updateSingleReadEvidence(state, origin, "/workspace")).toEqual({}); +}); +for (const [provider, create] of [["cursor", createCursorToolEvidence]] as const) { + describe(`${provider} passive single-read origin`, () => { + function setup() { const rows: any[] = []; const projector = create({ sessionId: "session", turnId: "turn", workingDirectory: "/workspace", active: () => true, + emit: event => { validateAcpxRichEvent(event); rows.push(Object.fromEntries((event.payload.details as any[]).map(d => [d.name, d.value]))); } }); return { rows, projector }; } + it.each(["completed", "failed"])("retains exact proof in %s terminal and does not emit raw path", status => { + const s = setup(); s.projector.tool(origin); s.projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "read", status }); + expect(s.rows.map(row => row.readTargetSha256)).toEqual([hash, hash]); expect(JSON.stringify(s.rows)).not.toContain(path); + const firstTerminal = setup(); firstTerminal.projector.tool({ ...origin, status }); expect(firstTerminal.rows[0].readTargetSha256).toBe(hash); + }); + it("marks changed same-path ambiguous input or locations incomplete without throwing through execution", () => { + for (const fields of [{ rawInput: { path, paths: [path, "private.txt"] } }, { locations: [{ path }, { path: "private.txt" }] }]) { + const s = setup(); s.projector.tool(origin); + expect(() => s.projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "read", status: "completed", ...fields })).not.toThrow(); + expect(s.rows.at(-1).stage).toBe("evidence_incomplete"); + } + }); + it("does not attest multi-file reads or inherit another tool's origin", () => { + const s = setup(); s.projector.tool({ ...origin, rawInput: { paths: [path, "private.txt"] } }); + s.projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "other", kind: "read", status: "completed", rawInput: { path } }); + expect(s.rows).toHaveLength(1); expect(s.rows[0].readTargetSha256).toBeUndefined(); + }); + }); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.ts b/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.ts new file mode 100644 index 00000000000..cbea42d835e --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.ts @@ -0,0 +1,47 @@ +import { createHash } from "node:crypto"; +import { safeAcpxLocations } from "./safe-locations.js"; + +export interface SingleReadEvidence { targetSha256?: string } +const record = (value: unknown): Record | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; +const digest = (value: string) => `sha256:${createHash("sha256").update(value).digest("hex")}`; +const scalarOptions = new Set(["offset", "limit", "startLine", "endLine"]); +function safePath(value: unknown, cwd: string): string | undefined { + if (typeof value !== "string" || !value || Buffer.byteLength(value) > 4096 || /[\u0000-\u001f\u007f\\]/u.test(value)) return; + const locations = safeAcpxLocations([{ path: value }], cwd, "read"); + const path = locations[0]?.path; + return locations.length === 1 && typeof path === "string" && !path.includes(":") ? path : undefined; +} +function locationsAgree(value: unknown, cwd: string, expected: string): boolean { + return value === undefined || (Array.isArray(value) && value.length <= 1 && value.every(entry => { + const location = record(entry); return location !== undefined && safePath(location.path, cwd) === expected; + })); +} +function singleInputPath(value: unknown, cwd: string): string | undefined { + const input = record(value); if (!input) return; + const keys = Object.keys(input), paths = keys.filter(key => key === "path" || key === "fileName"); + if (paths.length !== 1 || keys.length > 5 || keys.some(key => key !== "path" && key !== "fileName" + && (!scalarOptions.has(key) || typeof input[key] !== "number" || !Number.isSafeInteger(input[key]) || (input[key] as number) < 0))) return; + return safePath(input[paths[0]!], cwd); +} + +/** Passive attestation of the pinned single-file read input, never permission or + * filesystem authority. Multi-path tools and shell-output reads remain visible + * but cannot acquire this proof. Only the actual origin may establish a digest; + * absent update fields retain it, while changed or ambiguous fields invalidate + * evidence without changing the provider's response or execution. */ +export function updateSingleReadEvidence(state: SingleReadEvidence | undefined, call: Record, cwd: string): SingleReadEvidence { + if (!state) { + const path = call.tag === "tool_call" ? singleInputPath(call.rawInput, cwd) : undefined; + return path && locationsAgree(call.locations, cwd, path) ? { targetSha256: digest(path) } : {}; + } + if (!state.targetSha256) return state; // A later delta cannot create an origin. + if (call.rawInput !== undefined) { + const path = singleInputPath(call.rawInput, cwd); + if (!path || digest(path) !== state.targetSha256 || !locationsAgree(call.locations, cwd, path)) throw new Error("Changed single-read evidence input"); + } else if (call.locations !== undefined) { + if (!Array.isArray(call.locations) || call.locations.length > 1 || call.locations.some(location => { + const path = safePath(record(location)?.path, cwd); return !path || digest(path) !== state.targetSha256; + })) throw new Error("Changed single-read evidence locations"); + } + return state; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/turn-cancellation.test.ts b/packages/paperclip-runner/src/drivers/acpx/turn-cancellation.test.ts new file mode 100644 index 00000000000..4db3a55e142 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/turn-cancellation.test.ts @@ -0,0 +1,102 @@ +import type { AcpRuntimeTurnResult } from "acpx/runtime"; +import { afterEach, expect, it, vi } from "vitest"; +import { withAcpxTurnCancellation } from "./turn-cancellation.js"; +import type { AcpxRuntimeTurn } from "./runtime-host.js"; + +const deferred = () => { + let resolve!: (value: T) => void, reject!: (reason: unknown) => void; + const promise = new Promise((r, j) => { resolve = r; reject = j; }); + return { promise, resolve, reject }; +}; +function fixture() { + const terminal = deferred(); + const stream = deferred>(); + const cleanup = deferred(); + const cancel = vi.fn(async () => undefined), close = vi.fn(() => cleanup.promise); + const raw: AcpxRuntimeTurn = { + requestId: "turn", promptStarted: Promise.resolve(), result: terminal.promise, + cancel, closeStream: vi.fn(async () => undefined), + events: { [Symbol.asyncIterator]: () => ({ next: () => stream.promise }) }, + }; + return { terminal, stream, cleanup, cancel, close, turn: withAcpxTurnCancellation(raw, close, 20) }; +} +afterEach(() => vi.useRealTimers()); + +it("does not treat acknowledgement or stdout EOF during cleanup as terminal proof", async () => { + vi.useFakeTimers(); const f = fixture(); + const next = f.turn.events[Symbol.asyncIterator]().next(); + const stopped = f.turn.cancel({ reason: "operator stop" }); + let settled = false; void f.turn.result.finally(() => { settled = true; }); + await vi.advanceTimersByTimeAsync(19); + expect(f.cancel).toHaveBeenCalledExactlyOnceWith({ reason: "operator stop" }); + expect(f.close).not.toHaveBeenCalled(); expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(f.close).toHaveBeenCalledExactlyOnceWith("operator stop"); + f.stream.reject(new Error("stdout closed during cleanup")); + f.terminal.resolve({ status: "failed", error: { message: "EOF", retryable: false } }); + await Promise.resolve(); expect(settled).toBe(false); + f.cleanup.resolve(); + await stopped; + await expect(f.turn.result).resolves.toEqual({ status: "cancelled", stopReason: "cancelled_after_runtime_close" }); + await expect(next).resolves.toMatchObject({ done: true }); +}); + +it("never publishes cancellation when owned provider cleanup fails", async () => { + vi.useFakeTimers(); const f = fixture(); + const result = expect(f.turn.result).rejects.toThrow("provider still alive"); + const next = expect(f.turn.events[Symbol.asyncIterator]().next()).rejects.toThrow("provider still alive"); + const stopped = expect(f.turn.cancel()).rejects.toThrow("provider still alive"); + await vi.advanceTimersByTimeAsync(20); f.cleanup.reject(new Error("provider still alive")); + await Promise.all([result, next, stopped]); +}); + +it.each(["completed", "failed"] as const)("waits after clean EOF for %s owned cleanup", async outcome => { + vi.useFakeTimers(); const f = fixture(); + const next = f.turn.events[Symbol.asyncIterator]().next(); + const stopped = f.turn.cancel(); + const nextOutcome = next.then(() => "ended", () => "failed"); + let streamSettled = false; + void nextOutcome.then(() => { streamSettled = true; }); + const result = outcome === "failed" + ? expect(f.turn.result).rejects.toThrow("cleanup failed") + : expect(f.turn.result).resolves.toMatchObject({ status: "cancelled" }); + const cancellation = outcome === "failed" + ? expect(stopped).rejects.toThrow("cleanup failed") + : expect(stopped).resolves.toBeUndefined(); + f.stream.resolve({ done: true, value: undefined }); + await vi.advanceTimersByTimeAsync(20); + expect(f.close).toHaveBeenCalledOnce(); + expect(streamSettled).toBe(false); + if (outcome === "failed") f.cleanup.reject(new Error("cleanup failed")); + else f.cleanup.resolve(); + await Promise.all([result, cancellation]); + await expect(nextOutcome).resolves.toBe(outcome === "failed" ? "failed" : "ended"); +}); + +it.each(["completed", "cancelled", "failed"] as const)("settles a racing provider %s terminal without waiting for the run timeout", async status => { + const f = fixture(); + const stopped = f.turn.cancel(); + const terminal: AcpRuntimeTurnResult = status === "failed" ? { status, error: { message: "provider failure", retryable: false } } : { status }; + f.terminal.resolve(terminal); + await stopped; + await expect(f.turn.result).resolves.toEqual(status === "completed" ? { status: "cancelled", stopReason: "cancelled_after_provider_completed" } : terminal); + expect(f.close).not.toHaveBeenCalled(); +}); + +it("keeps a provider transport failure distinct from a cancellation timeout", async () => { + const f = fixture(); + const result = expect(f.turn.result).rejects.toThrow("provider stdout closed"); + const stopped = expect(f.turn.cancel()).rejects.toThrow("provider stdout closed"); + f.terminal.reject(new Error("provider stdout closed")); + await Promise.all([result, stopped]); expect(f.close).not.toHaveBeenCalled(); +}); + +it("drains ordinary output even when its normal result is already settled", async () => { + const f = fixture(); + f.terminal.resolve({ status: "completed" }); + const iterator = f.turn.events[Symbol.asyncIterator](); + f.stream.resolve({ value: { type: "text_delta", text: "final bytes" }, done: false }); + await expect(iterator.next()).resolves.toEqual({ value: { type: "text_delta", text: "final bytes" }, done: false }); + await iterator.return?.(); + await expect(f.turn.result).resolves.toEqual({ status: "completed" }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/turn-cancellation.ts b/packages/paperclip-runner/src/drivers/acpx/turn-cancellation.ts new file mode 100644 index 00000000000..971a280043b --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/turn-cancellation.ts @@ -0,0 +1,95 @@ +import type { AcpRuntimeTurnResult } from "acpx/runtime"; +import type { AcpxRuntimeTurn } from "./runtime-host.js"; + +/** An ACP cancel acknowledgement is not terminal evidence. Await a provider + * terminal or owned runtime retirement before ending the caller's stream. + */ +export function withAcpxTurnCancellation( + raw: AcpxRuntimeTurn, + closeRuntime: (reason: string) => Promise, + graceMs: number, +): AcpxRuntimeTurn { + let cancellation: Promise | undefined; + const retirement = new AbortController(); + let retire!: (value: AcpRuntimeTurnResult) => void; + let failRetirement!: (error: unknown) => void; + const retired = new Promise((resolve, reject) => { + retire = resolve; failRetirement = reject; + }); + void retired.catch(() => undefined); + const cancel = (input?: { reason?: string }): Promise => { + if (!cancellation) { + const reason = input?.reason ?? "Paperclip cancellation"; + // Install the gate before invoking provider code, including synchronous + // callback cancellation and a terminal racing the acknowledgement. + cancellation = Promise.resolve().then(async () => { + void Promise.resolve().then(() => raw.cancel({ reason })).catch(() => undefined); + let timer: ReturnType | undefined; + const terminal = await Promise.race([ + raw.result, + new Promise(resolve => { timer = setTimeout(() => resolve(null), graceMs); }), + ]).finally(() => clearTimeout(timer)); + if (terminal !== null) { + return terminal.status === "completed" + ? { status: "cancelled" as const, stopReason: "cancelled_after_provider_completed" } + : terminal; + } + // closeRuntime owns process identities and lifetime leases. A rejected + // close must never turn an unproven process exit into cancellation. + await closeRuntime(reason); + return { status: "cancelled" as const, stopReason: "cancelled_after_runtime_close" }; + }); + void cancellation.then( + value => { retire(value); retirement.abort(); }, + error => { failRetirement(error); retirement.abort(); }, + ); + } + return cancellation.then(() => undefined); + }; + const result = Promise.race([ + raw.result.then(value => cancellation ?? value, error => cancellation ?? Promise.reject(error)), + retired, + ]); + void result.catch(() => undefined); + return { + requestId: raw.requestId, + promptStarted: raw.promptStarted, + result, + cancel, + closeStream: input => raw.closeStream(input), + events: (async function* () { + const iterator = raw.events[Symbol.asyncIterator](); + try { + while (true) { + if (retirement.signal.aborted) { await retired; return; } + let onRetirement!: () => void; + const interrupted = new Promise<{ kind: "retired" }>((resolve, reject) => { + onRetirement = () => { void retired.then(() => resolve({ kind: "retired" }), reject); }; + retirement.signal.addEventListener("abort", onRetirement, { once: true }); + }); + const next = await Promise.race([ + iterator.next().then(value => ({ kind: "event" as const, value })), + interrupted, + ]).finally(() => retirement.signal.removeEventListener("abort", onRetirement)).catch(async error => { + if (!cancellation) throw error; + // EOF during owned close is not itself proof that cleanup worked. + await cancellation; + return { kind: "retired" as const }; + }); + if (next.kind === "retired") return; + if (next.value.done) { + // Clean EOF is no stronger than a transport error: when Stop owns + // settlement, keep the stream open until terminal/cleanup proof. + if (cancellation) await cancellation; + return; + } + yield next.value.value; + } + } finally { + // Some provider iterators await a stuck RPC even after retirement. + // Do not make their optional return() another unbounded shutdown wait. + void iterator.return?.().catch(() => undefined); + } + })(), + }; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts index 2d8c2ff3579..4143f655a68 100644 --- a/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts +++ b/packages/paperclip-runner/src/drivers/acpx/usage-accounting.ts @@ -95,3 +95,70 @@ 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 }, + }; +} diff --git a/packages/paperclip-runner/src/live/cursor-usage-notice.ts b/packages/paperclip-runner/src/live/cursor-usage-notice.ts new file mode 100644 index 00000000000..f31b0c301f4 --- /dev/null +++ b/packages/paperclip-runner/src/live/cursor-usage-notice.ts @@ -0,0 +1,98 @@ +import type { CanonicalProviderEvent } from "../provider-events.js"; + +const SUMMARY = "Cursor reported partial native counters. These observations are not authoritative token usage or billing cost."; +const PROVENANCE = "Cursor native turnEnded observations; partial, unsummed, unverified counter semantics"; +const REASONS = new Set([ + "native_counter_semantics_unverified", "native_counters_missing", "invalid_native_counter", + "multiple_terminal_observations", "native_terminal_not_observed", "observation_limit_reached", + "child_run_attribution_unverified", +]); +const COUNTERS = new Set(["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"]); +const object = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); +const exact = (value: Record, keys: readonly string[]): boolean => + Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)); +const positive = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0; + +/** + * The eval transport wraps rich events in a Codex-shaped notification. Admit + * only the closed Cursor diagnostic, never arbitrary canonical provider data. + * This notice no longer contains the private prompt/request receipt IDs, so + * validate its projected shape without inventing a persisted usage receipt. + * Neither these unsummed observations nor their absence are accounting facts. + */ +export function cursorUsageNotice( + raw: unknown, + owner: { provider?: string; agent?: string; threadId: string; turnId: string | null }, +): CanonicalProviderEvent | null { + try { + if (owner.provider !== "acpx" || owner.agent !== "cursor" || !owner.threadId || !owner.turnId + || !object(raw) || !exact(raw, ["threadId", "turnId", "eventType", "itemId", "payload"]) + || raw.threadId !== owner.threadId || raw.turnId !== owner.turnId + || raw.eventType !== "provider.notice.recorded" || typeof raw.itemId !== "string" + || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(raw.itemId)) return null; + // Runnerd's envelope item is the durable authority item. The native notice + // keeps its own display identity inside the payload; these are independent. + const p = raw.payload; + if (!object(p) || !exact(p, ["schema", "noticeId", "severity", "category", "scope", "recoverable", "userActionable", "summary", "details"]) + || p.schema !== "paperclip.provider.notice.v1" || typeof p.noticeId !== "string" + || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(p.noticeId) + || p.severity !== "info" || p.category !== "cursor_native_usage_observed" || p.scope !== "turn" + || p.recoverable !== true || p.userActionable !== false || p.summary !== SUMMARY + || !Array.isArray(p.details) || p.details.length < 3 || p.details.length > 11) return null; + // Bound strings before JSON parsing/cloning. The collector's complete + // envelope is <=16 KiB; its observation strings cannot exceed that budget. + const details: Array<{ name: string; value: string }> = []; + let bytes = 0; + for (const detail of p.details) { + if (!object(detail) || !exact(detail, ["name", "value"]) || typeof detail.name !== "string" + || detail.name.length > 64 || typeof detail.value !== "string" || detail.value.length > 4_000) return null; + bytes += Buffer.byteLength(detail.value, "utf8"); + if (bytes > 16_384) return null; + details.push({ name: detail.name, value: detail.value }); + } + if (details[0]!.name !== "Provenance" || details[0]!.value !== PROVENANCE + || details[1]!.name !== "Partial reasons" || details[2]!.name !== "Collector truncated" + || !["true", "false"].includes(details[2]!.value)) return null; + const reasons = details[1]!.value.split(", "); + if (!reasons.includes("native_counter_semantics_unverified") || reasons.length > REASONS.size + || new Set(reasons).size !== reasons.length || !reasons.every(reason => REASONS.has(reason))) return null; + const invocations = new Map(); + let count = 0; + const retained = details.slice(0, 3); + for (let group = 3; group < details.length; group++) { + const values: unknown = JSON.parse(details[group]!.value); + if (!Array.isArray(values) || values.length < 1 || values.length > 8 + || (group < details.length - 1 && values.length !== 8)) return null; + if (details[group]!.name !== `Native observations ${count + 1}-${count + values.length}`) return null; + const observations = []; + for (const value of values) { + if (!object(value) || !exact(value, ["invocationId", "role", "nativeRun", "sequence", "counters"]) + || typeof value.invocationId !== "string" || !/^invocation-([1-9]|[1-5][0-9]|6[0-4])$/.test(value.invocationId) + || (value.role !== "parent" && value.role !== "child") || !positive(value.nativeRun) + || !positive(value.sequence) || !object(value.counters)) return null; + const counters: Record = {}; + for (const [key, number] of Object.entries(value.counters)) { + if (!COUNTERS.has(key) || !Number.isSafeInteger(number) || Number(number) < 0) return null; + counters[key] = Number(number); + } + const prior = invocations.get(value.invocationId); + if (prior && (prior.role !== value.role || prior.nativeRun !== value.nativeRun || prior.sequence >= value.sequence)) return null; + invocations.set(value.invocationId, { role: value.role, nativeRun: value.nativeRun, sequence: value.sequence }); + observations.push({ invocationId: value.invocationId, role: value.role, nativeRun: value.nativeRun, sequence: value.sequence, counters }); + } + count += observations.length; + if (count > 64) return null; + retained.push({ name: details[group]!.name, value: JSON.stringify(observations) }); + } + return { + eventType: "provider.notice.recorded", itemId: raw.itemId, + payload: { schema: "paperclip.provider.notice.v1", noticeId: p.noticeId, severity: "info", + category: "cursor_native_usage_observed", scope: "turn", recoverable: true, userActionable: false, + summary: SUMMARY, details: retained }, + }; + } catch { + // Optional diagnostics, including malformed JSON, cannot poison settlement. + return null; + } +} diff --git a/packages/paperclip-runner/src/live/live-session.ts b/packages/paperclip-runner/src/live/live-session.ts index 28ec49d172d..a89183ded92 100644 --- a/packages/paperclip-runner/src/live/live-session.ts +++ b/packages/paperclip-runner/src/live/live-session.ts @@ -1,3 +1,4 @@ +import { cursorUsageNotice } from "./cursor-usage-notice.js"; import { liveRunResultFeedback } from "./run-result-feedback.js"; import { createHash, randomUUID } from "node:crypto"; import { resolve } from "node:path"; @@ -2757,6 +2758,29 @@ export class CapabilityLiveSession { async #handleNotification(notification: CodexRpcNotification): Promise { const params = notification.params; + if (notification.method === "paperclip/canonicalProviderEvent" + && params.eventType === "provider.notice.recorded" + && record(params.payload).category === "cursor_native_usage_observed") { + const notice = cursorUsageNotice(params, { + provider: this.#config.provider, agent: this.#config.acpxAgent, + threadId: this.#providerThreadId, turnId: this.#activeTurnId, + }); + if (notice && !this.#evidence.some(entry => entry.turnId === this.#activeTurnId + && entry.kind === "provider_event" && entry.data.canonical === true + && record(entry.data.payload).category === "cursor_native_usage_observed")) { + this.#appendEvidence("provider_event", this.#activeTurnId, { + canonicalEventType: notice.eventType, itemId: notice.itemId, payload: notice.payload, + }); + try { + await this.#persist(); + } catch { + // These counters are optional diagnostics. Keep the evidence in the + // snapshot so #persist's recovered queue can retry on the next state + // transition. Authoritative terminal saves must still succeed. + } + } + return; + } const turn = record(params.turn); const item = record(params.item); const turnId = text(params.turnId, text(turn.id)); diff --git a/packages/paperclip-runner/src/live/runnerd-acpx-permission-forwarding.test.ts b/packages/paperclip-runner/src/live/runnerd-acpx-permission-forwarding.test.ts new file mode 100644 index 00000000000..14708520b6a --- /dev/null +++ b/packages/paperclip-runner/src/live/runnerd-acpx-permission-forwarding.test.ts @@ -0,0 +1,117 @@ +import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { DurableRecoveryCommittedEvent, DurableRecoveryIdentity } from "../contracts/durable-recovery.js"; +import type { CodexSessionState } from "../drivers/codex/codex-session-state.js"; +import type { HarnessRuntimeRequest } from "../contracts/harness-driver.js"; + +// Replace only the PRP peer and process boundary. The production transport, +// event pump, registered handler, and request emission below all run unchanged. +const peer = vi.hoisted(() => ({ current: null as TestPeer | null })); +interface TestPeer { + store: { state: { identity: DurableRecoveryIdentity; committedEvents: DurableRecoveryCommittedEvent[]; commands: Array> } }; + emit(type: string, payload: Record): void; +} +vi.mock("../control-plane/durable-prp-control-plane.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawnRunner: vi.fn(() => ({ child: { exitCode: null }, completion: new Promise(() => {}) })), + DurablePrpControlPlane: class implements TestPeer { + store: TestPeer["store"]; + connectUrl = "ws://fixture.invalid"; + constructor(options: { identity: DurableRecoveryIdentity }) { + this.store = { state: { identity: options.identity, committedEvents: [], commands: [] } }; + peer.current = this; + } + async start() {} + async stop() {} + disconnectActiveRunner() {} + persistRunAttachTemplate() {} + issueBootstrapTicket() { return {}; } + getCommand(id: string) { return this.store.state.commands.find(c => c.commandId === id); } + queueCommand(type: string, payload: Record, id = type) { + this.store.state.commands.push({ type, commandId: id, status: "completed", result: { result: { providerTurnId: payload.turnId } } }); + if (type === "session.open") this.emit("session.started", { + threadId: "thread-1", sessionId: "provider-session-1", + runtimeIdentity: { executionKind: "remote_service" }, + }); + if (type === "turn.start") this.emit("turn.started", { providerTurnId: payload.turnId }); + } + emit(eventType: string, payload: Record) { + const sourceSeq = this.store.state.committedEvents.length + 1; + this.store.state.committedEvents.push({ + eventType, sourceSeq, sourceEventId: `fixture:${sourceSeq}`, + priority: 0, deliveryCount: 1, logicalEffectCount: 1, + envelope: { turnId: this.store.state.identity.turnId, payload: { payload } }, + }); + } + }, + }; +}); +import { createCapabilityRunnerdCodexTransport } from "./runnerd-codex-transport.js"; +import { handleServerRequest } from "../drivers/codex/codex-session-server-requests.js"; + +describe("permission events through the actual transport handler", () => { + it.each([ + { name: "native ID distinct from opaque item", details: { toolCallId: "native-edit-7" }, expected: "native-edit-7" }, + { name: "absent native ID", details: {}, expected: undefined }, + { name: "malformed native ID", details: { toolCallId: { forged: "opaque-item-7" } }, expected: undefined }, + ])("emits retained control-plane details for $name", async ({ details, expected }) => { + const root = mkdtempSync(join(tmpdir(), "permission-forwarding-")); + const binary = join(root, "unexecuted-runner"); + writeFileSync(binary, "not executable: process boundary is mocked"); + const bundle = createCapabilityRunnerdCodexTransport({ + runnerBinary: binary, stateDirectory: root, + sourceCodexHome: join(root, "absent-source-home"), + environment: { HOME: root, PATH: "/usr/bin:/bin" }, + }); + const emitted: HarnessRuntimeRequest[] = []; + const pending = new Map(); + const state = { + sourceSequence: 0, runnerInstanceId: "fixture-runner", runId: "fixture-run", + opened: { threadId: "thread-1" }, activeTurnId: "", terminal: false, protocolFailed: false, + pendingRuntimeRequestMap: pending, transport: bundle.transport, + failProtocol: vi.fn((code: string) => { throw new Error(code); }), + emit(type: string, payload: { request?: HarnessRuntimeRequest }) { + this.sourceSequence++; + if (type === "runtime_request.created") { + emitted.push(structuredClone(payload.request!)); + writeFileSync(join(root, "emitted-requests.json"), JSON.stringify(emitted)); + } + }, + }; + const handled = vi.fn(request => handleServerRequest(state as unknown as CodexSessionState, request)); + bundle.transport.setServerRequestHandler(handled); + let drain: Promise | undefined; + try { + await bundle.transport.request("initialize", {}); + await bundle.transport.request("thread/start", { cwd: root }); + const start = await bundle.transport.request("turn/start", { input: [{ type: "text", text: "Fixture" }] }); + state.activeTurnId = (start.turn as { id: string }).id; + // The real harness consumes notifications before dispatching a bridged + // request at its position in the durable event stream. + drain = (async () => { for await (const _notification of bundle.transport.notifications()) { /* Consume the fixture's turn start. */ } })(); + peer.current!.emit("runtime_request.created", { request: { + requestId: "permission-7", type: "permission", requestKind: "permission_approval", + itemId: "opaque-item-7", prompt: "Change file: result.txt", details, + choices: [{ key: "decline", label: "Deny" }], + origin: { adapter: "acpx-runtime-sidecar", provider: "copilot", method: "session/request_permission" }, + } }); + await vi.waitFor(() => expect(emitted).toHaveLength(1), { timeout: 1_000, interval: 5 }); + expect(handled).toHaveBeenCalledTimes(1); + const retained = JSON.parse(readFileSync(join(root, "emitted-requests.json"), "utf8"))[0]; + expect(retained).toMatchObject({ requestId: "permission-7", itemId: "opaque-item-7", requestKind: "permission_approval", turnId: state.activeTurnId }); + if (expected === undefined) expect(retained.details).not.toHaveProperty("toolCallId"); + else expect(retained.details.toolCallId).toBe(expected); + expect(retained.details.choices).toEqual([{ key: "decline", label: "Deny" }]); + expect(state.failProtocol).not.toHaveBeenCalled(); + } finally { + for (const entry of pending.values()) entry.settle({ outcome: "cancel" }); + await bundle.detachControllerForRestart(); + await drain; + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/paperclip-runner/src/live/runnerd-acpx-permission-ordering.test.ts b/packages/paperclip-runner/src/live/runnerd-acpx-permission-ordering.test.ts new file mode 100644 index 00000000000..3da30eae8f7 --- /dev/null +++ b/packages/paperclip-runner/src/live/runnerd-acpx-permission-ordering.test.ts @@ -0,0 +1,221 @@ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { DurableRecoveryCommittedEvent, DurableRecoveryIdentity } from "../contracts/durable-recovery.js"; + +// Replace only the PRP peer and process boundary. The production transport, +// event pump, registered handler, and request emission below all run unchanged. +const peer = vi.hoisted(() => ({ current: null as TestPeer | null })); +interface TestPeer { + store: { state: { identity: DurableRecoveryIdentity; committedEvents: DurableRecoveryCommittedEvent[]; commands: Array> } }; + emit(type: string, payload: Record): void; +} +vi.mock("../control-plane/durable-prp-control-plane.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawnRunner: vi.fn(() => ({ child: { exitCode: null }, completion: new Promise(() => {}) })), + DurablePrpControlPlane: class implements TestPeer { + store: TestPeer["store"]; + connectUrl = "ws://fixture.invalid"; + constructor(options: { identity: DurableRecoveryIdentity }) { + this.store = { state: { identity: options.identity, committedEvents: [], commands: [] } }; + peer.current = this; + } + async start() {} + async stop() {} + disconnectActiveRunner() {} + persistRunAttachTemplate() {} + issueBootstrapTicket() { return {}; } + getCommand(id: string) { return this.store.state.commands.find(c => c.commandId === id); } + queueCommand(type: string, payload: Record, id = type) { + this.store.state.commands.push({ type, commandId: id, status: "completed", result: { result: { providerTurnId: payload.turnId } } }); + if (type === "session.open") this.emit("session.started", { + threadId: "thread-1", sessionId: "provider-session-1", + runtimeIdentity: { executionKind: "remote_service" }, + }); + if (type === "turn.start") this.emit("turn.started", { providerTurnId: payload.turnId }); + } + emit(eventType: string, payload: Record) { + const sourceSeq = this.store.state.committedEvents.length + 1; + this.store.state.committedEvents.push({ + eventType, sourceSeq, sourceEventId: `fixture:${sourceSeq}`, + priority: 0, deliveryCount: 1, logicalEffectCount: 1, + envelope: { turnId: this.store.state.identity.turnId, payload: { payload } }, + }); + } + }, + }; +}); +import { cursorToolIdentity, cursorToolExecutionId } from "../drivers/acpx/cursor-plan-tool-identity.js"; +import { normalizeAcpxPermission } from "../drivers/acpx/acp-permission-adapter.js"; +import { createCapabilityRunnerdCodexTransport } from "./runnerd-codex-transport.js"; +import { makeDriver, WORKSPACE } from "../drivers/codex/codex-app-server-driver.test-support.js"; +import type { PrpEvent } from "../protocol/replay-contract.js"; + + +describe("permission and tool order through the actual transport and harness", () => { + it.each(["tool-first", "permission-first"].flatMap(order => ["native-tool-7", "native\u0080tool", "native\u0085tool", "native\u009ftool", "tool/1", "x".repeat(161)].map(rawId => ({ order, rawId }))))("preserves committed batch order and native identity with an unanswered permission: $order $rawId", async ({ order, rawId }) => { + const toolCallId = cursorToolIdentity(rawId); + const executionId = cursorToolExecutionId(rawId); + const normalized = normalizeAcpxPermission({ sessionId: "provider-session-1", inferredKind: "execute", raw: { + sessionId: "provider-session-1", toolCall: { toolCallId: rawId, kind: "execute" }, + options: [{ kind: "reject_once", optionId: "original-native-denial", name: "Deny" }], + } }, { provider: "cursor" }); + expect(normalized.toolCallId).toBe(toolCallId); + const root = mkdtempSync(join(tmpdir(), "permission-ordering-")); + const binary = join(root, "unexecuted-runner"); + writeFileSync(binary, "not executable: process boundary is mocked"); + const bundle = createCapabilityRunnerdCodexTransport({ + runnerBinary: binary, stateDirectory: root, + sourceCodexHome: join(root, "absent-source-home"), + environment: { HOME: root, PATH: "/usr/bin:/bin" }, + }); + const driver = makeDriver([], { transportFactory: () => bundle.transport }); + const seen: PrpEvent[] = []; + let session: Awaited> | undefined; + let drain: Promise | undefined; + try { + session = await driver.openSession({ runId: "ordering-run", normalizedSessionId: "ordering-session", workingDirectory: WORKSPACE }); + const { turnId } = await session.startTurn({ message: { role: "user", text: "Fixture" } }); + drain = (async () => { for await (const event of session!.events()) { + seen.push(event); + if (seen.some(e => e.eventType === "runtime_request.created") && seen.some(e => e.eventType === "tool.execution.started")) break; + } })(); + const tool = () => peer.current!.emit("tool.execution.started", { + schema: "paperclip.tool.execution.v1", executionId, transport: "builtin", namespace: null, + name: "Run command", operation: "execute", target: null, status: "running", readOnly: false, + inputUpdated: true, output: null, outputBytes: 0, outputTruncated: false, outputDigest: null, + durationMs: null, exitCode: null, progress: [], + }); + const permission = () => peer.current!.emit("runtime_request.created", { request: { + requestId: "permission-7", type: "permission", requestKind: "permission_approval", + itemId: "opaque-item-7", prompt: "Run command", details: { toolCallId: normalized.toolCallId }, + choices: [{ key: "decline", label: "Deny" }, { key: "cancel", label: "Cancel" }], + origin: { adapter: "acpx-runtime-sidecar", provider: "cursor", method: "session/request_permission" }, + } }); + // Both durable events exist before the production pump's next tick. + // Keep the permission unanswered while the independent tool pump drains. + if (order === "tool-first") { tool(); permission(); } else { permission(); tool(); } + await vi.waitFor(() => expect(seen.filter(e => ["tool.execution.started", "runtime_request.created"].includes(e.eventType))).toHaveLength(2), { timeout: 1_000, interval: 5 }); + const nativeOrder = peer.current!.store.state.committedEvents.filter(e => ["tool.execution.started", "runtime_request.created"].includes(e.eventType)).map(e => e.eventType); + const projectedOrder = seen.filter(e => ["tool.execution.started", "runtime_request.created"].includes(e.eventType)).map(e => e.eventType); + expect(session.pendingRuntimeRequests!()).toMatchObject([{ requestId: "permission-7", turnId, status: "pending", details: { toolCallId } }]); + expect(projectedOrder).toEqual(nativeOrder); + // Permission details retain bounded native identity; activity retains + // Rust's opaque execution ID. Their deterministic join is intentional. + expect(seen.find(e => e.eventType === "tool.execution.started")?.payload).toMatchObject({ executionId }); + if (rawId === "tool/1" || rawId.length === 161) expect(executionId).not.toBe(toolCallId); + } finally { + // This test owns no actual process or database. Retire the mocked peer + // and its controller timer without inventing a provider terminal event. + await bundle.detachControllerForRestart(); + await drain; + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +async function bareTransport() { + const root = mkdtempSync(join(tmpdir(), "permission-queue-lifecycle-")); + const binary = join(root, "unexecuted-runner"); + writeFileSync(binary, "not executable: process boundary is mocked"); + const bundle = createCapabilityRunnerdCodexTransport({ runnerBinary: binary, stateDirectory: root, + sourceCodexHome: join(root, "absent-source-home"), environment: { HOME: root, PATH: "/usr/bin:/bin" }, + }); + await bundle.transport.request("initialize", {}); + await bundle.transport.request("thread/start", { cwd: root }); + await bundle.transport.request("turn/start", { input: [{ type: "text", text: "Fixture" }] }); + const iterator = bundle.transport.notifications()[Symbol.asyncIterator](); + expect((await iterator.next()).value?.method).toBe("turn/started"); + const emitPermission = (requestId = "permission-7", prompt = "Run command") => peer.current!.emit("runtime_request.created", { request: { + requestId, type: "permission", requestKind: "permission_approval", itemId: requestId, prompt, + choices: [{ key: "cancel", label: "Cancel" }], details: { toolCallId: "tool-7" }, + origin: { adapter: "acpx-runtime-sidecar", provider: "cursor", method: "session/request_permission" }, + } }); + const emitMarker = () => peer.current!.emit("item.delta", { kind: "agentMessage", text: "marker" }); + return { bundle, iterator, emitPermission, emitMarker, async close() { await bundle.detachControllerForRestart(); rmSync(root, { recursive: true, force: true }); } }; +} + +it("discards a queued permission on close before the notification consumer asks for next", async () => { + const fixture = await bareTransport(); const handler = vi.fn(async () => ({})); + fixture.bundle.transport.setServerRequestHandler(handler); + try { + fixture.emitMarker(); fixture.emitPermission(); + expect((await fixture.iterator.next()).value?.method).toBe("item/agentMessage/delta"); + expect(handler).not.toHaveBeenCalled(); + await fixture.bundle.detachControllerForRestart(); + await expect(fixture.iterator.next()).resolves.toMatchObject({ done: true }); + expect(handler).not.toHaveBeenCalled(); + } finally { await fixture.close(); } +}); + +it("does not dispatch an expired durable request when its consumer resumes", async () => { + const fixture = await bareTransport(); const handler = vi.fn(async () => ({})); + fixture.bundle.transport.setServerRequestHandler(handler); + try { + fixture.emitPermission(); + peer.current!.emit("runtime_request.expired", { requestId: "permission-7" }); + fixture.emitMarker(); + expect((await fixture.iterator.next()).value?.method).toBe("item/agentMessage/delta"); + expect(handler).not.toHaveBeenCalled(); + } finally { await fixture.close(); } +}); + +it("does not dispatch queued approval callbacks into a replacement turn", async () => { + const fixture = await bareTransport(); const handler = vi.fn(async () => ({})); + fixture.bundle.transport.setServerRequestHandler(handler); + try { + fixture.emitMarker(); fixture.emitPermission(); + expect((await fixture.iterator.next()).value?.method).toBe("item/agentMessage/delta"); + await fixture.bundle.transport.request("turn/start", { input: [{ type: "text", text: "Next turn" }] }); + expect((await fixture.iterator.next()).value?.method).toBe("turn/started"); + expect(handler).not.toHaveBeenCalled(); + } finally { await fixture.close(); } +}); + +it.each(["throw", "reject"])("closes the queue and wakes its consumer on handler %s", async failure => { + const fixture = await bareTransport(); + const handler = vi.fn(() => { if (failure === "throw") throw new Error("fixture handler failed"); return Promise.reject(new Error("fixture handler failed")); }); + fixture.bundle.transport.setServerRequestHandler(handler); + try { + fixture.emitPermission(); + await expect(fixture.iterator.next()).rejects.toThrow("fixture handler failed"); + expect(handler).toHaveBeenCalledTimes(1); + await expect(fixture.iterator.next()).rejects.toThrow("fixture handler failed"); + } finally { await fixture.close(); } +}); + +it.each(["count", "bytes"])("bounds queued permission callback %s without a consumer", async bound => { + const fixture = await bareTransport(); const handler = vi.fn(async () => ({})); + fixture.bundle.transport.setServerRequestHandler(handler); + try { + if (bound === "count") for (let i = 0; i < 2049; i++) fixture.emitPermission(`permission-${i}`); + else fixture.emitPermission("oversized", "x".repeat(4 * 1024 * 1024)); + // next waits until the actual event pump reaches the closed queue limit. + await expect(fixture.iterator.next()).rejects.toThrow("notification queue bound exceeded"); + expect(handler).not.toHaveBeenCalled(); + } finally { await fixture.close(); } +}); + +it("wakes an idle notification consumer during controller shutdown", async () => { + const fixture = await bareTransport(); + try { + const pending = fixture.iterator.next(); + await fixture.bundle.detachControllerForRestart(); + await expect(pending).resolves.toMatchObject({ done: true }); + } finally { await fixture.close(); } +}); + +it("does not transfer a queued request to a newly registered handler", async () => { + const fixture = await bareTransport(); const original = vi.fn(async () => ({})), replacement = vi.fn(async () => ({})); + fixture.bundle.transport.setServerRequestHandler(original); + try { + fixture.emitMarker(); fixture.emitPermission(); fixture.emitMarker(); + expect((await fixture.iterator.next()).value?.method).toBe("item/agentMessage/delta"); + fixture.bundle.transport.setServerRequestHandler(replacement); + expect((await fixture.iterator.next()).value?.method).toBe("item/agentMessage/delta"); + expect(original).not.toHaveBeenCalled(); expect(replacement).not.toHaveBeenCalled(); + } finally { await fixture.close(); } +}); diff --git a/packages/paperclip-runner/src/live/runnerd-acpx-permissions.test.ts b/packages/paperclip-runner/src/live/runnerd-acpx-permissions.test.ts new file mode 100644 index 00000000000..f3c308800bc --- /dev/null +++ b/packages/paperclip-runner/src/live/runnerd-acpx-permissions.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { bridgedAcpxPermissionParams } from "./runnerd-codex-transport.js"; +import { boundedCodexValue, redactCodexValue } from "../drivers/codex/codex-boundaries.js"; + +// Shape retained in runtime_request.created after the sidecar -> Rust boundary. +// The opaque item ID is deliberately different from the native correlation ID. +function retainedPermission(details: unknown = { toolCallId: "call_native-edit-7" }) { + return { + requestId: "acpx-request-permission-7", + type: "permission", + requestKind: "permission_approval", + itemId: "acpx-permission-opaque-7", + prompt: "Change file: result.txt", + choices: [{ key: "decline", label: "Deny" }, { key: "cancel", label: "Cancel" }], + origin: { adapter: "acpx-runtime-sidecar", provider: "copilot", method: "session/request_permission" }, + details, + }; +} + +function committedEvent(request = retainedPermission()) { + return { + eventType: "runtime_request.created", + envelope: { payload: { payload: { request } } }, + }; +} + +describe("ACPX permission correlation through the shared runner transport", () => { + it.each(["copilot", "cursor", "pi"])("preserves the original %s tool ID in the control-plane details", (provider) => { + const request = retainedPermission(); + request.origin.provider = provider; + const before = structuredClone(request); + const params = bridgedAcpxPermissionParams(committedEvent(request), "thread-1", "turn-1")!; + expect(params).toEqual({ + threadId: "thread-1", turnId: "turn-1", itemId: request.itemId, + reason: request.prompt, choices: request.choices, origin: request.origin, + toolCallId: "call_native-edit-7", + }); + // The existing server-request handler applies these boundaries before + // emitting HarnessRuntimeRequest.details to the control plane. + expect(redactCodexValue(boundedCodexValue(params))).toMatchObject({ + itemId: "acpx-permission-opaque-7", toolCallId: "call_native-edit-7", + }); + expect(request).toEqual(before); + }); + + it.each(["x".repeat(240), "é".repeat(240), " opaque tool ID "])("does not rewrite a valid bounded identity", (toolCallId) => { + expect(bridgedAcpxPermissionParams(committedEvent(retainedPermission({ toolCallId })), "t", "u")!.toolCallId).toBe(toolCallId); + }); + + it.each([undefined, null, "", " ", 12, true, {}, [], "x".repeat(241), "bad\nID", "bad\0ID", "bad\x7fID", "bad\x85ID"])("does not fabricate correlation for invalid identity %#", (toolCallId) => { + const request = { ...retainedPermission({ toolCallId }), toolCallId: "wrong-top-level-id" }; + const params = bridgedAcpxPermissionParams(committedEvent(request), "t", "u")!; + expect(params).not.toHaveProperty("toolCallId"); + expect(params.itemId).toBe(request.itemId); + expect(params.choices).toEqual(request.choices); + }); + + it.each([null, undefined, [], "tool-id", 12])("does not infer identity from malformed details %#", (details) => { + const request = { ...retainedPermission(), details }; + expect(bridgedAcpxPermissionParams(committedEvent(request), "t", "u")).not.toHaveProperty("toolCallId"); + }); +}); + +it("does not project unrelated or malformed committed events as permission requests", () => { + for (const event of [ + { ...committedEvent(), eventType: "runtime_request.resolved" }, + committedEvent({ ...retainedPermission(), type: "question" }), + committedEvent({ ...retainedPermission(), requestKind: "user_input" }), + committedEvent({ ...retainedPermission(), origin: { adapter: "acpx-runtime-sidecar", provider: "copilot", method: "cursor/ask_question" } }), + { eventType: "runtime_request.created", envelope: { payload: null } }, + ]) expect(bridgedAcpxPermissionParams(event, "t", "u")).toBeNull(); +}); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index c067f2a541e..7ef6b789acc 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -67,7 +67,7 @@ import { createSanitizedAwsAgentCoreEnvironment, createSanitizedClaudeManagedEnvironment, } from "../drivers/claude-managed/environment.js"; -import type { NativeRuntimeContextSnapshot } from "../contracts/runtime-context.js"; +import { composeNativeSystemInstructions, type NativeRuntimeContextSnapshot } from "../contracts/runtime-context.js"; import type { NativeAcpxPermissionMode, NativeOpenCodePermissionMode, @@ -421,6 +421,8 @@ function rotatedRunAttachPayload( authorizedTools: Record | null, completionContract: { revision: string; criterionIds: readonly string[] } | undefined, + runtimeContext?: NativeRuntimeContextSnapshot | null, + currentInstructions?: { text: string; context: NativeRuntimeContextSnapshot | null }, ): Record { const commands = Array.isArray(state.commands) ? state.commands.map(record) @@ -446,21 +448,63 @@ function rotatedRunAttachPayload( desired, authorizedTools, completionContract, + runtimeContext, + currentInstructions, ); } +function retargetComposedInstructions( + instructions: string, + prior: NativeRuntimeContextSnapshot, + current: NativeRuntimeContextSnapshot | null, +): string { + const priorPrefix = prior.prompt.text; + const priorSuffix = composeNativeSystemInstructions(prior, "").slice(priorPrefix.length); + // Legacy callers can supply opaque system instructions. Only the composer's + // exact framing identifies a trusted asset block; never rewrite their text. + if (!instructions.startsWith(priorPrefix) || !instructions.endsWith(priorSuffix)) { + return instructions; + } + const custom = instructions.slice(priorPrefix.length, -priorSuffix.length); + if (custom && !custom.startsWith("\n\n")) return instructions; + // Keep custom entry bytes intact, including historical path examples or an + // identical paragraph quoted inside the entry. Only replace the final block. + return (current?.prompt.text ?? priorPrefix) + custom + (current + ? composeNativeSystemInstructions(current, "").slice(current.prompt.text.length) + : ""); +} + function retargetRunAttachPayload( seedPayload: Record, desired: DurableRecoveryIdentity, authorizedTools: Record | null, completionContract: { revision: string; criterionIds: readonly string[] } | undefined, + runtimeContext?: NativeRuntimeContextSnapshot | null, + currentInstructions?: { text: string; context: NativeRuntimeContextSnapshot | null }, ): Record { const payload = structuredClone(seedPayload); const provider = record(payload.provider); if (provider.kind === "acpx" || provider.provider === "acpx") { provider.runId = desired.runId; provider.normalizedSessionId = desired.normalizedSessionId; + // A run-scoped registered instruction copy is collected after shutdown. + // Restore the durable provider identity with the current authenticated + // context, never the prior run's now-stale filesystem grant. + if (runtimeContext !== undefined) { + if (currentInstructions !== undefined) { + provider.instructions = currentInstructions.context + ? retargetComposedInstructions(currentInstructions.text, currentInstructions.context, runtimeContext) + : currentInstructions.text; + } else if (typeof provider.instructions === "string" && provider.runtimeContext) { + provider.instructions = retargetComposedInstructions( + provider.instructions, + provider.runtimeContext as NativeRuntimeContextSnapshot, + runtimeContext, + ); + } + provider.runtimeContext = structuredClone(runtimeContext); + } payload.provider = provider; } if (authorizedTools !== null) payload.authorizedTools = authorizedTools; @@ -868,6 +912,33 @@ async function awaitAdoptedRunnerAuthentication(input: { } } +/** Preserve the sidecar identity independently of Rust's opaque item ID. */ +export function bridgedAcpxPermissionParams( + event: Pick, + threadId: string, + turnId: string, +): Record | null { + const request = record(record(record(event.envelope.payload).payload).request); + if (event.eventType !== "runtime_request.created" + || request.type !== "permission" || request.requestKind !== "permission_approval" + || record(request.origin).method !== "session/request_permission") return null; + const toolCallId = record(request.details).toolCallId; + // Match the permission adapter's 240-character bound. Never truncate, trim, + // hash, or substitute itemId: any of those would change the correlation key. + const validToolCallId = typeof toolCallId === "string" + && toolCallId.trim().length > 0 && toolCallId.length <= 240 + && !/[\u0000-\u001f\u007f-\u009f]/.test(toolCallId); + return { + threadId, + turnId, + itemId: request.itemId, + reason: request.prompt, + choices: request.choices, + origin: record(request.origin), + ...(validToolCallId ? { toolCallId } : {}), + }; +} + export function bridgedCodexQuestionParams( request: Record, method: string, @@ -1085,6 +1156,7 @@ export interface CapabilityRunnerdCodexTransportOptions { /** Explicit evaluation-only candidate selection, never derived from persisted session input. */ acpxCandidateProfile?: "pi" | "cursor" | "copilot"; acpxPermissionMode?: NativeAcpxPermissionMode; + acpxCursorMode?: "agent" | "plan" | "ask"; acpxPermissionModePinned?: boolean; acpxSidecarPath?: string; /** SHA-256 verified by the provider-pack authority before runner startup. */ @@ -1419,32 +1491,37 @@ export function resolveRunnerdSessionIdentity(input: unknown): { }; } +type NotificationQueueEntry = + | { kind: "notification"; value: CodexRpcNotification; bytes: number } + | { kind: "control"; dispatch(): void; bytes: number }; + class NotificationQueue implements AsyncIterable { - #values: Array<{ value: CodexRpcNotification; bytes: number }> = []; - #waiters: Array<{ - resolve: (value: IteratorResult) => void; - reject: (error: Error) => void; - }> = []; + #values: NotificationQueueEntry[] = []; + #waiters: Array<() => void> = []; #bytes = 0; #closed = false; #error: Error | null = null; push(value: CodexRpcNotification): void { if (this.#closed) return; - const bytes = Buffer.byteLength(JSON.stringify(value)); - const waiter = this.#waiters.shift(); - if (waiter !== undefined) { - waiter.resolve({ value, done: false }); - return; - } - if ( - this.#values.length >= MAX_NOTIFICATION_COUNT || - this.#bytes + bytes > MAX_NOTIFICATION_BYTES - ) { + this.#push({ kind: "notification", value, bytes: Buffer.byteLength(JSON.stringify(value)) }); + } + + pushControl(value: CodexRpcServerRequest, dispatch: () => void): void { + if (this.#closed) return; + // Count the retained request just like notification payloads. The callback + // is internal; a provider notification cannot create a control entry. + this.#push({ kind: "control", dispatch, bytes: Buffer.byteLength(JSON.stringify(value)) }); + } + + #push(entry: NotificationQueueEntry): void { + if (this.#closed) return; + if (this.#values.length >= MAX_NOTIFICATION_COUNT || this.#bytes + entry.bytes > MAX_NOTIFICATION_BYTES) { throw new Error("PRP provider notification queue bound exceeded"); } - this.#values.push({ value, bytes }); - this.#bytes += bytes; + this.#values.push(entry); + this.#bytes += entry.bytes; + this.#waiters.shift()?.(); } close(error?: Error): void { @@ -1453,25 +1530,29 @@ class NotificationQueue implements AsyncIterable { this.#error = error ?? null; this.#values = []; this.#bytes = 0; - for (const waiter of this.#waiters.splice(0)) { - if (this.#error !== null) waiter.reject(this.#error); - else waiter.resolve({ value: undefined, done: true }); - } + for (const wake of this.#waiters.splice(0)) wake(); } [Symbol.asyncIterator](): AsyncIterator { return { next: async () => { - const queued = this.#values.shift(); - if (queued !== undefined) { - this.#bytes -= queued.bytes; - return { value: queued.value, done: false }; + for (;;) { + if (this.#error !== null) throw this.#error; + if (this.#closed) return { value: undefined, done: true }; + const queued = this.#values.shift(); + if (queued !== undefined) { + this.#bytes -= queued.bytes; + if (queued.kind === "control") { + // The consumer asks for next only after mapping the previous + // notification. Start the request here without awaiting the + // human response, so subsequent provider activity keeps flowing. + queued.dispatch(); + continue; + } + return { value: queued.value, done: false }; + } + await new Promise(resolve => this.#waiters.push(resolve)); } - if (this.#error !== null) throw this.#error; - if (this.#closed) return { value: undefined, done: true }; - return new Promise((resolveValue, reject) => - this.#waiters.push({ resolve: resolveValue, reject }), - ); }, }; } @@ -3429,6 +3510,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { readonly #bridgedRuntimeInputs = new Map(); constructor(readonly options: CapabilityRunnerdCodexTransportOptions) { + if (options.acpxCursorMode !== undefined && (options.provider !== "acpx" || options.acpxAgent !== "cursor" + || !["agent", "plan", "ask"].includes(options.acpxCursorMode))) { + throw new Error("acpxCursorMode must be agent, plan, or ask and is supported only for Cursor"); + } if (options.adoptExistingRunner && !options.stateDirectory?.trim()) { throw new Error("native_adopted_runner_state_directory_required"); } @@ -4615,6 +4700,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { cwd: String(params.cwd ?? tmpdir()), instructions: baseInstructions, providerPolicy: { readOnly: params.permissions === "paperclip-runner-workspace-read-only" }, + ...(acpxProfile!.agent === "cursor" ? { cursorMode: this.options.acpxCursorMode ?? "agent" } : {}), permissionMode: resolveRunnerdAcpxPermissionMode( this.options.acpxPermissionMode, ), @@ -5154,6 +5240,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { desiredIdentity, this.#authorizedTools, this.options.resumeCompletionContract, + runtimeContext, + this.options.baseInstructions === undefined + ? undefined + : { text: this.options.baseInstructions, context: sourceRuntimeContext }, ); if (provider === "codex") { // These controller-owned, token-free paths belong to the new run. @@ -6009,11 +6099,11 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const method = typeof origin.method === "string" ? origin.method : ""; const permission = normalizedRequest.type === "permission" && normalizedRequest.requestKind === "permission_approval" && method === "session/request_permission"; - const params = permission ? { - threadId: this.#threadId, turnId: this.#turnId, - itemId: normalizedRequest.itemId, reason: normalizedRequest.prompt, - choices: normalizedRequest.choices, origin, - } : bridgedCodexQuestionParams( + const params = permission ? bridgedAcpxPermissionParams( + event, + this.#threadId, + this.#turnId, + ) : bridgedCodexQuestionParams( normalizedRequest, method, this.#threadId, @@ -6028,14 +6118,15 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { isAcpxCanonicalInputMethod(method) || permission) && !this.#bridgedRuntimeInputs.has(requestId) ) { - this.#bridgedRuntimeInputs.set(requestId, { + const binding = { permission, durableTurnId: typeof event.envelope.turnId === "string" ? event.envelope.turnId : this.#durableTurnId, - }); - void this.#handler({ + }; + this.#bridgedRuntimeInputs.set(requestId, binding); + const request: CodexRpcServerRequest = { id: requestId, method, params, @@ -6043,10 +6134,21 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { sourceEventId: event.sourceEventId, sourceEventType: event.eventType, }, - }).catch((error) => { - this.#failTransport( - error instanceof Error ? error : new Error(String(error)), - ); + }; + const threadId = this.#threadId, turnId = this.#turnId, handler = this.#handler; + this.#queue.pushControl(request, () => { + // A detached controller, replaced turn or settled durable request + // cannot acquire approval authority when a slow consumer resumes. + if (this.#closed || this.#failure || this.#core !== core || this.#handler !== handler + || this.#threadId !== threadId || this.#turnId !== turnId + || this.#bridgedRuntimeInputs.get(requestId) !== binding) return; + try { + void handler(request).catch((error) => { + this.#failTransport(error instanceof Error ? error : new Error(String(error))); + }); + } catch (error) { + this.#failTransport(error instanceof Error ? error : new Error(String(error))); + } }); } continue; 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 new file mode 100644 index 00000000000..41ae321c280 --- /dev/null +++ b/packages/paperclip-runner/test/acpx-cursor-subagents-package-contract.test.mjs @@ -0,0 +1,109 @@ +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 fixture = String.raw` +const readline=require('node:readline'); +const send=value=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\n'); +const child=(kind,sessionId='parent',extra={})=>({method:'session/update',params:{sessionId,update:{sessionUpdate:kind,subagentSessionId:'child',name:'Explore',task:'Read source',_meta:{cursor:{toolCallId:'task-1',agentId:'native-child',model:'small'}},...extra}}}); +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(child('subagent_spawned'));send({id:message.id,result:{sessionId:'parent'}});} + else if(message.method==='session/prompt'){ + const mode=message.params.prompt[0].text; + if(mode==='valid'){ + send(child('subagent_spawned')); + send({method:'session/update',params:{sessionId:'child',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:'child output'}}}}); + send(child('subagent_spawned','child',{subagentSessionId:'grandchild'})); + send(child('subagent_state_update','child',{subagentSessionId:'grandchild',state:'completed'})); + send(child('subagent_state_update','parent',{state:'completed'})); + }else if(mode==='foreign'){ + send(child('subagent_spawned','other-parent')); + const missing=child('subagent_spawned');delete missing.params.sessionId;send(missing); + send({method:'session/update',params:{sessionId:'unowned-child',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:'must not become parent text'}}}}); + }else if(mode==='oversized')send(child('subagent_spawned','parent',{task:'x'.repeat(300000)})); + else if(mode==='unknown')send(child('subagent_deleted')); + send({method:'session/update',params:{sessionId:'parent',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:'done'}}}}); + send({id:message.id,result:{stopReason:'end_turn'}}); + } +}); +`; + +async function withClient(options, run) { + const cwd = await mkdtemp(join(tmpdir(), "paperclip-cursor-child-")); + const client = new AcpClient({ + agentCommand: "paperclip-verified-cursor-fixture", cwd, + permissionMode: "approve-all", fs: false, terminal: false, authPolicy: "skip", + suppressSdkConsoleErrors: true, + extensionMethods: ["cursor/subagent_update"], clientCapabilities: { _meta: { subagents: true } }, + spawnAgent: () => spawn(process.execPath, ["-e", fixture], { cwd, stdio: ["pipe", "pipe", "pipe"] }), + ...options, + }); + try { await client.start(); await client.createSession(); await run(client); } + finally { await client.close(); await rm(cwd, { recursive: true, force: true }); } +} + +test("pinned Cursor children survive the SDK boundary while retaining parent and native metadata", { timeout: 10000 }, async () => { + const notifications = []; const standard = []; + await withClient({ onExtensionNotification: (method, params) => notifications.push({ method, params }), onSessionUpdate: value => standard.push(value) }, async client => { + assert.equal(notifications.length, 0, "pre-turn child event is not admitted"); + assert.equal((await client.prompt("parent", "valid")).stopReason, "end_turn"); + assert.deepEqual(notifications.map(value => value.method), Array(5).fill("cursor/subagent_update")); + assert.deepEqual(notifications.map(value => value.params.update.sessionUpdate), ["subagent_spawned", "agent_message_chunk", "subagent_spawned", "subagent_state_update", "subagent_state_update"]); + assert.equal(notifications[0].params.sessionId, "parent"); + assert.equal(notifications[0].params.update._meta.cursor.model, "small"); + assert.equal(notifications[1].params.childSessionId, "child"); + assert.equal(notifications[1].params.sessionId, "parent"); + assert.equal(notifications[2].params.parentSessionId, "child"); + assert.equal(notifications[2].params.sessionId, "parent"); + assert.equal(standard.length, 1, "standard output still follows SDK validation"); + }); +}); + +test("child interception refuses foreign/missing parent, oversized data and unknown variants", { timeout: 10000 }, async () => { + const notifications = []; const standard = []; + await withClient({ onExtensionNotification: (...args) => notifications.push(args), onSessionUpdate: value => standard.push(value) }, async client => { + for (const mode of ["foreign", "oversized", "unknown"]) await client.prompt("parent", mode); + assert.deepEqual(notifications, []); + assert.equal(standard.length, 3); + assert.ok(standard.every(value => value.sessionId === "parent")); + }); +}); + +test("subagent compatibility is opt-in and rejects old stream, settled turn and aborted connection authority", { timeout: 10000 }, async () => { + for (const options of [{ extensionMethods: [] }, { clientCapabilities: {} }]) { + const notifications = []; + await withClient({ ...options, onExtensionNotification: (...args) => notifications.push(args) }, async client => { + await client.prompt("parent", "valid"); + assert.deepEqual(notifications, []); + }); + } + const notifications = []; + const client = new AcpClient({ agentCommand: "fixture", cwd: tmpdir(), permissionMode: "approve-all", extensionMethods: ["cursor/subagent_update"], clientCapabilities: { _meta: { subagents: true } }, onExtensionNotification: (...args) => notifications.push(args) }); + const current = { active: null, children: new Map() }; const old = { active: null, children: new Map() }; + const turn = new AbortController(); const connection = new AbortController(); + client.cursorSubagentStreamOwner = current; + client.activePrompt = { sessionId: "parent", elicitationController: turn }; + client.connection = { signal: connection.signal }; + const message = { method: "session/update", params: { sessionId: "parent", update: { sessionUpdate: "subagent_spawned", subagentSessionId: "child" } } }; + assert.equal(client.handleCursorSubagentNotification(message, old), true); + assert.equal(notifications.length, 0); + client.handleCursorSubagentNotification(message, current); + assert.equal(notifications.length, 1); + turn.abort(); client.handleCursorSubagentNotification(message, current); + assert.equal(notifications.length, 1); + client.activePrompt = { sessionId: "parent", elicitationController: new AbortController() }; + connection.abort(); client.handleCursorSubagentNotification(message, current); + assert.equal(notifications.length, 1); + client.activePrompt = null; client.handleCursorSubagentNotification(message, current); + assert.equal(notifications.length, 1); +}); 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 new file mode 100644 index 00000000000..483f533182f --- /dev/null +++ b/packages/paperclip-runner/test/acpx-cursor-usage-package-contract.test.mjs @@ -0,0 +1,173 @@ +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-sidecar-contract.test.mjs b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs index 770f9ffece4..9213288907e 100644 --- a/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs @@ -135,3 +135,16 @@ test("turn control schema preserves explicit modes and rejects open-ended dispat assert.equal(validate({ ...message, params }), false); } }); + +// Runtime payloads are provider-owned; the sidecar envelope stays closed. +// Pi validates native provenance before creating these boundary/history fields. +test("Pi native empty message boundaries and replay history fit the strict sidecar envelope", () => { + for (const payload of [ + { type: "text_delta", stream: "output", text: "", messageId: "message-1", piMessageBoundary: { phase: "start" } }, + { type: "text_delta", stream: "output", text: "", messageId: "message-1", piMessageBoundary: { phase: "end", stopReason: "toolUse" } }, + { type: "text_delta", stream: "output", text: "Prior assistant reply", messageId: "history-1", piMessageHistory: true }, + ]) { + assert.equal(validate({ ...messages[2], payload }), true, JSON.stringify(validate.errors)); + assert.equal(validate({ ...messages[2], payload, piMessageBoundary: { phase: "start" } }), false); + } +}); diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/authenticated-capability-probe.json b/packages/paperclip-runner/test/fixtures/cursor-acp/authenticated-capability-probe.json new file mode 100644 index 00000000000..56e292ba0ee --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/authenticated-capability-probe.json @@ -0,0 +1,2271 @@ +{ + "schema": "paperclip-cursor-authenticated-capability-probe/v1", + "capturedAt": "2026-09-28T19:09:06.609Z", + "runnerSourceRevision": "653ea40135b1dc6586b8de4ebf49ad5befbc6e66", + "packSourceRevision": "a92d06ca5940a42d45cba937f19ba7e7e9c03018", + "providerVersion": "2026.09.26-dd393fe", + "credentialKind": "explicit CURSOR_AUTH_TOKEN; value withheld", + "modelPromptCalls": 0, + "scope": "Native ACP discovery and configuration through verified installation lease; not runner/product inference qualification", + "modelCatalog": [ + { + "value": "default", + "name": "Auto", + "configOptions": [] + }, + { + "value": "grok-4.7", + "name": "Grok 4.7", + "configOptions": [ + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "256k", + "options": [ + { + "value": "256k", + "name": "256K" + }, + { + "value": "500k", + "name": "500K" + } + ] + }, + { + "id": "reasoning_effort", + "name": "Effort", + "description": "Controls how much reasoning effort the model uses before responding.", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "xhigh", + "name": "Extra High" + } + ] + }, + { + "id": "fast", + "name": "Fast", + "description": "Significantly faster but consumes more usage", + "category": "model_config", + "type": "select", + "currentValue": "true", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "Fast\u200b\u200b" + } + ] + } + ] + }, + { + "value": "grok-4.6", + "name": "Grok 4.6", + "configOptions": [ + { + "id": "effort", + "name": "Effort", + "description": "Effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "xhigh", + "name": "Extra High" + } + ] + }, + { + "id": "fast", + "name": "Fast", + "description": "Significantly faster but consumes more usage", + "category": "model_config", + "type": "select", + "currentValue": "true", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "Fast\u200b\u200b" + } + ] + } + ] + }, + { + "value": "composer-2.5", + "name": "Composer 2.5", + "configOptions": [ + { + "id": "fast", + "name": "Fast", + "description": "Significantly faster but consumes more usage", + "category": "model_config", + "type": "select", + "currentValue": "true", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "Fast" + } + ] + } + ] + }, + { + "value": "claude-opus-5-5", + "name": "Claude Opus 5.5", + "configOptions": [ + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "300k", + "options": [ + { + "value": "300k", + "name": "300K" + }, + { + "value": "1m", + "name": "1M" + } + ] + }, + { + "id": "effort", + "name": "Effort", + "description": "Effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "xhigh", + "name": "Extra High" + }, + { + "value": "max", + "name": "Max" + } + ] + }, + { + "id": "fast", + "name": "Fast", + "description": "2x more expensive, but significantly faster speeds.", + "category": "model_config", + "type": "select", + "currentValue": "false", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "Fast" + } + ] + } + ] + }, + { + "value": "claude-opus-5", + "name": "Claude Opus 5", + "configOptions": [ + { + "id": "thinking", + "name": "Thinking", + "description": "Does the model use thinking to generate its response?", + "category": "thought_level", + "type": "select", + "currentValue": "true", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "On" + } + ] + }, + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "300k", + "options": [ + { + "value": "300k", + "name": "300K" + }, + { + "value": "1m", + "name": "1M" + } + ] + }, + { + "id": "effort", + "name": "Effort", + "description": "Effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "xhigh", + "name": "Extra High" + }, + { + "value": "max", + "name": "Max" + } + ] + }, + { + "id": "fast", + "name": "Fast", + "description": "2x more expensive, but significantly faster speeds.", + "category": "model_config", + "type": "select", + "currentValue": "false", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "Fast" + } + ] + } + ] + }, + { + "value": "claude-opus-4-8", + "name": "Claude Opus 4.8", + "configOptions": [ + { + "id": "thinking", + "name": "Thinking", + "description": "Does the model use thinking to generate its response?", + "category": "thought_level", + "type": "select", + "currentValue": "true", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "On" + } + ] + }, + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "300k", + "options": [ + { + "value": "300k", + "name": "300K" + }, + { + "value": "1m", + "name": "1M" + } + ] + }, + { + "id": "effort", + "name": "Effort", + "description": "Effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "xhigh", + "name": "Extra High" + }, + { + "value": "max", + "name": "Max" + } + ] + }, + { + "id": "fast", + "name": "Fast", + "description": "2x more expensive, but significantly faster speeds.", + "category": "model_config", + "type": "select", + "currentValue": "false", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "Fast" + } + ] + } + ] + }, + { + "value": "gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "configOptions": [ + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "272k", + "options": [ + { + "value": "272k", + "name": "272K" + }, + { + "value": "1m", + "name": "1M" + } + ] + }, + { + "id": "reasoning", + "name": "Reasoning", + "description": "Reasoning effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { + "value": "none", + "name": "None" + }, + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "xhigh", + "name": "Extra High" + }, + { + "value": "max", + "name": "Max" + } + ] + }, + { + "id": "fast", + "name": "Fast", + "description": "2x more expensive, but faster speeds.", + "category": "model_config", + "type": "select", + "currentValue": "false", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "Fast" + } + ] + } + ] + }, + { + "value": "gpt-5.5", + "name": "GPT-5.5", + "configOptions": [ + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "272k", + "options": [ + { + "value": "272k", + "name": "272K" + }, + { + "value": "1m", + "name": "1M" + } + ] + }, + { + "id": "reasoning", + "name": "Reasoning", + "description": "Reasoning effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { + "value": "none", + "name": "None" + }, + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "extra-high", + "name": "Extra High" + } + ] + }, + { + "id": "fast", + "name": "Fast", + "description": "2.5x more expensive, but faster speeds.", + "category": "model_config", + "type": "select", + "currentValue": "false", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "Fast" + } + ] + } + ] + }, + { + "value": "claude-fable-5-1", + "name": "Claude Fable 5.1", + "configOptions": [ + { + "id": "thinking", + "name": "Thinking", + "description": "Does the model use thinking to generate its response?", + "category": "thought_level", + "type": "select", + "currentValue": "true", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "On" + } + ] + }, + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "300k", + "options": [ + { + "value": "300k", + "name": "300K" + }, + { + "value": "1m", + "name": "1M" + } + ] + }, + { + "id": "effort", + "name": "Effort", + "description": "Effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "xhigh", + "name": "Extra High" + }, + { + "value": "max", + "name": "Max" + } + ] + } + ] + }, + { + "value": "claude-fable-5", + "name": "Claude Fable 5", + "configOptions": [ + { + "id": "thinking", + "name": "Thinking", + "description": "Does the model use thinking to generate its response?", + "category": "thought_level", + "type": "select", + "currentValue": "true", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "On" + } + ] + }, + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "300k", + "options": [ + { + "value": "300k", + "name": "300K" + }, + { + "value": "1m", + "name": "1M" + } + ] + }, + { + "id": "effort", + "name": "Effort", + "description": "Effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "xhigh", + "name": "Extra High" + }, + { + "value": "max", + "name": "Max" + } + ] + } + ] + }, + { + "value": "grok-4.5", + "name": "Grok 4.5", + "configOptions": [ + { + "id": "effort", + "name": "Effort", + "description": "Effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + } + ] + }, + { + "id": "fast", + "name": "Fast", + "description": "Significantly faster but consumes more usage", + "category": "model_config", + "type": "select", + "currentValue": "true", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "Fast\u200b" + } + ] + } + ] + }, + { + "value": "gemini-3.8-flash", + "name": "Gemini 3.8 Flash", + "configOptions": [ + { + "id": "reasoning_effort", + "name": "Effort", + "description": "Effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + } + ] + } + ] + }, + { + "value": "gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "configOptions": [ + { + "id": "effort", + "name": "Effort", + "description": "Effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + } + ] + } + ] + }, + { + "value": "muse-spark-1.3", + "name": "Muse Spark 1.3", + "configOptions": [ + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "300k", + "options": [ + { + "value": "300k", + "name": "300K" + }, + { + "value": "1m", + "name": "1M" + } + ] + }, + { + "id": "effort", + "name": "Effort", + "description": "Effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { + "value": "minimal", + "name": "Minimal" + }, + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "xhigh", + "name": "Extra High" + }, + { + "value": "max", + "name": "Max" + } + ] + } + ] + }, + { + "value": "gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "configOptions": [ + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "272k", + "options": [ + { + "value": "272k", + "name": "272K" + }, + { + "value": "1m", + "name": "1M" + } + ] + }, + { + "id": "reasoning", + "name": "Reasoning", + "description": "Reasoning effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { + "value": "none", + "name": "None" + }, + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "xhigh", + "name": "Extra High" + }, + { + "value": "max", + "name": "Max" + } + ] + }, + { + "id": "fast", + "name": "Fast", + "description": "2x more expensive, but faster speeds.", + "category": "model_config", + "type": "select", + "currentValue": "false", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "Fast" + } + ] + } + ] + }, + { + "value": "claude-sonnet-5-5", + "name": "Claude Sonnet 5.5", + "configOptions": [ + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "300k", + "options": [ + { + "value": "300k", + "name": "300K" + }, + { + "value": "1m", + "name": "1M" + } + ] + }, + { + "id": "reasoning_effort", + "name": "Effort", + "description": "Effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "xhigh", + "name": "Extra High" + }, + { + "value": "max", + "name": "Max" + } + ] + } + ] + }, + { + "value": "claude-sonnet-5", + "name": "Claude Sonnet 5", + "configOptions": [ + { + "id": "thinking", + "name": "Thinking", + "description": "Does the model use thinking to generate its response?", + "category": "thought_level", + "type": "select", + "currentValue": "true", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "On" + } + ] + }, + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "300k", + "options": [ + { + "value": "300k", + "name": "300K" + }, + { + "value": "1m", + "name": "1M" + } + ] + }, + { + "id": "effort", + "name": "Effort", + "description": "Effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "xhigh", + "name": "Extra High" + }, + { + "value": "max", + "name": "Max" + } + ] + } + ] + }, + { + "value": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "configOptions": [ + { + "id": "thinking", + "name": "Thinking", + "description": "Does the model use thinking to generate its response?", + "category": "thought_level", + "type": "select", + "currentValue": "true", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "On" + } + ] + }, + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "200k", + "options": [ + { + "value": "200k", + "name": "200K" + }, + { + "value": "1m", + "name": "1M" + } + ] + }, + { + "id": "effort", + "name": "Effort", + "description": "Effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "max", + "name": "Max" + } + ] + } + ] + }, + { + "value": "gpt-5.3-codex", + "name": "Codex 5.3", + "configOptions": [ + { + "id": "reasoning", + "name": "Reasoning", + "description": "Reasoning effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "extra-high", + "name": "Extra High" + } + ] + }, + { + "id": "fast", + "name": "Fast", + "description": "2x more expensive, but faster speeds.", + "category": "model_config", + "type": "select", + "currentValue": "false", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "Fast" + } + ] + } + ] + }, + { + "value": "claude-opus-4-7", + "name": "Claude Opus 4.7", + "configOptions": [ + { + "id": "thinking", + "name": "Thinking", + "description": "Does the model use thinking to generate its response?", + "category": "thought_level", + "type": "select", + "currentValue": "true", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "On" + } + ] + }, + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "300k", + "options": [ + { + "value": "300k", + "name": "300K" + }, + { + "value": "1m", + "name": "1M" + } + ] + }, + { + "id": "effort", + "name": "Effort", + "description": "Effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "xhigh", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "xhigh", + "name": "Extra High" + }, + { + "value": "max", + "name": "Max" + } + ] + }, + { + "id": "fast", + "name": "Fast", + "description": "6x more expensive, but significantly faster speeds.", + "category": "model_config", + "type": "select", + "currentValue": "false", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "Fast" + } + ] + } + ] + }, + { + "value": "gpt-5.4", + "name": "GPT-5.4", + "configOptions": [ + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "272k", + "options": [ + { + "value": "272k", + "name": "272K" + }, + { + "value": "1m", + "name": "1M" + } + ] + }, + { + "id": "reasoning", + "name": "Reasoning", + "description": "Reasoning effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { + "value": "none", + "name": "None" + }, + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "extra-high", + "name": "Extra High" + } + ] + }, + { + "id": "fast", + "name": "Fast", + "description": "2x more expensive, but faster speeds.", + "category": "model_config", + "type": "select", + "currentValue": "false", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "Fast" + } + ] + } + ] + }, + { + "value": "claude-opus-4-6", + "name": "Claude Opus 4.6", + "configOptions": [ + { + "id": "thinking", + "name": "Thinking", + "description": "Does the model use thinking to generate its response?", + "category": "thought_level", + "type": "select", + "currentValue": "true", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "On" + } + ] + }, + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "200k", + "options": [ + { + "value": "200k", + "name": "200K" + }, + { + "value": "1m", + "name": "1M" + } + ] + }, + { + "id": "effort", + "name": "Effort", + "description": "Effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "max", + "name": "Max" + } + ] + } + ] + }, + { + "value": "claude-opus-4-5", + "name": "Claude Opus 4.5", + "configOptions": [ + { + "id": "thinking", + "name": "Thinking", + "description": "Does the model use thinking to generate its response?", + "category": "thought_level", + "type": "select", + "currentValue": "true", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "On" + } + ] + } + ] + }, + { + "value": "gpt-5.2", + "name": "GPT-5.2", + "configOptions": [ + { + "id": "reasoning", + "name": "Reasoning", + "description": "Reasoning effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "extra-high", + "name": "Extra High" + } + ] + }, + { + "id": "fast", + "name": "Fast", + "description": "2x more expensive, but faster speeds.", + "category": "model_config", + "type": "select", + "currentValue": "false", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "Fast" + } + ] + } + ] + }, + { + "value": "gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "configOptions": [ + { + "id": "context", + "name": "Context", + "description": "Context size the model has available.", + "category": "model_config", + "type": "select", + "currentValue": "272k", + "options": [ + { + "value": "272k", + "name": "272K" + }, + { + "value": "1m", + "name": "1M" + } + ] + }, + { + "id": "reasoning", + "name": "Reasoning", + "description": "Reasoning effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { + "value": "none", + "name": "None" + }, + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "xhigh", + "name": "Extra High" + }, + { + "value": "max", + "name": "Max" + } + ] + }, + { + "id": "fast", + "name": "Fast", + "description": "2x more expensive, but faster speeds.", + "category": "model_config", + "type": "select", + "currentValue": "false", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "Fast" + } + ] + } + ] + }, + { + "value": "gemini-3.6-flash", + "name": "Gemini 3.6 Flash", + "configOptions": [ + { + "id": "effort", + "name": "Effort", + "description": "Effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { + "value": "minimal", + "name": "Minimal" + }, + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + } + ] + } + ] + }, + { + "value": "gemini-3.1-pro", + "name": "Gemini 3.1 Pro", + "configOptions": [] + }, + { + "value": "gpt-5.4-mini", + "name": "GPT-5.4 Mini", + "configOptions": [ + { + "id": "reasoning", + "name": "Reasoning", + "description": "Reasoning effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { + "value": "none", + "name": "None" + }, + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "xhigh", + "name": "Extra High" + } + ] + } + ] + }, + { + "value": "gpt-5.4-nano", + "name": "GPT-5.4 Nano", + "configOptions": [ + { + "id": "reasoning", + "name": "Reasoning", + "description": "Reasoning effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { + "value": "none", + "name": "None" + }, + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "xhigh", + "name": "Extra High" + } + ] + } + ] + }, + { + "value": "claude-haiku-4-5", + "name": "Claude Haiku 4.5", + "configOptions": [ + { + "id": "thinking", + "name": "Thinking", + "description": "Does the model use thinking to generate its response?", + "category": "thought_level", + "type": "select", + "currentValue": "true", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "On" + } + ] + } + ] + }, + { + "value": "claude-sonnet-4-5", + "name": "Claude Sonnet 4.5", + "configOptions": [ + { + "id": "thinking", + "name": "Thinking", + "description": "Does the model use thinking to generate its response?", + "category": "thought_level", + "type": "select", + "currentValue": "true", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "On" + } + ] + } + ] + }, + { + "value": "gpt-5.1", + "name": "GPT-5.1", + "configOptions": [ + { + "id": "reasoning", + "name": "Reasoning", + "description": "Reasoning effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "medium", + "name": "Medium" + }, + { + "value": "high", + "name": "High" + } + ] + } + ] + }, + { + "value": "gemini-3-flash", + "name": "Gemini 3 Flash", + "configOptions": [] + }, + { + "value": "gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "configOptions": [] + }, + { + "value": "claude-sonnet-4", + "name": "Claude Sonnet 4", + "configOptions": [ + { + "id": "thinking", + "name": "Thinking", + "description": "Does the model use thinking to generate its response?", + "category": "thought_level", + "type": "select", + "currentValue": "false", + "options": [ + { + "value": "false", + "name": "Off" + }, + { + "value": "true", + "name": "On" + } + ] + } + ] + }, + { + "value": "gpt-5-mini", + "name": "GPT-5 Mini", + "configOptions": [] + }, + { + "value": "gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "configOptions": [] + }, + { + "value": "kimi-k3", + "name": "Kimi K3", + "configOptions": [ + { + "id": "reasoning", + "name": "Reasoning", + "description": "Reasoning effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "max", + "options": [ + { + "value": "low", + "name": "Low" + }, + { + "value": "high", + "name": "High" + }, + { + "value": "max", + "name": "Max" + } + ] + } + ] + }, + { + "value": "kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "configOptions": [] + }, + { + "value": "glm-5.2", + "name": "GLM 5.2", + "configOptions": [ + { + "id": "reasoning", + "name": "Reasoning", + "description": "Reasoning effort the model uses to generate its response.", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { + "value": "high", + "name": "High" + }, + { + "value": "max", + "name": "Max" + } + ] + } + ] + } + ], + "ordinaryPicker": { + "sessionCreationSucceeded": true, + "availableModels": [ + { + "modelId": "default[]", + "name": "Auto" + }, + { + "modelId": "grok-4.7[context=256k,reasoning_effort=high,fast=true]", + "name": "grok-4.7" + }, + { + "modelId": "grok-4.6[effort=high,fast=true]", + "name": "grok-4.6" + }, + { + "modelId": "composer-2.5[fast=true]", + "name": "composer-2.5" + }, + { + "modelId": "claude-opus-5-5[context=300k,effort=medium,fast=false]", + "name": "claude-opus-5-5" + }, + { + "modelId": "claude-opus-5[thinking=true,context=300k,effort=high,fast=false]", + "name": "claude-opus-5" + }, + { + "modelId": "claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]", + "name": "claude-opus-4-8" + }, + { + "modelId": "gpt-5.6-sol[context=272k,reasoning=medium,fast=false]", + "name": "gpt-5.6-sol" + }, + { + "modelId": "gpt-5.5[context=272k,reasoning=medium,fast=false]", + "name": "gpt-5.5" + }, + { + "modelId": "claude-fable-5-1[thinking=true,context=300k,effort=high]", + "name": "claude-fable-5-1" + }, + { + "modelId": "claude-fable-5[thinking=true,context=300k,effort=high]", + "name": "claude-fable-5" + }, + { + "modelId": "grok-4.5[effort=high,fast=true]", + "name": "grok-4.5" + }, + { + "modelId": "gemini-3.8-flash[reasoning_effort=high]", + "name": "gemini-3.8-flash" + }, + { + "modelId": "gemini-3.7-flash[effort=high]", + "name": "gemini-3.7-flash" + }, + { + "modelId": "muse-spark-1.3[context=300k,effort=high]", + "name": "muse-spark-1.3" + }, + { + "modelId": "gpt-5.6-terra[context=272k,reasoning=medium,fast=false]", + "name": "gpt-5.6-terra" + }, + { + "modelId": "claude-sonnet-5-5[context=300k,reasoning_effort=high]", + "name": "claude-sonnet-5-5" + }, + { + "modelId": "claude-sonnet-5[thinking=true,context=300k,effort=high]", + "name": "claude-sonnet-5" + }, + { + "modelId": "claude-sonnet-4-6[thinking=true,context=200k,effort=medium]", + "name": "claude-sonnet-4-6" + }, + { + "modelId": "gpt-5.3-codex[reasoning=medium,fast=false]", + "name": "gpt-5.3-codex" + }, + { + "modelId": "claude-opus-4-7[thinking=true,context=300k,effort=xhigh,fast=false]", + "name": "claude-opus-4-7" + }, + { + "modelId": "gpt-5.4[context=272k,reasoning=medium,fast=false]", + "name": "gpt-5.4" + }, + { + "modelId": "claude-opus-4-6[thinking=true,context=200k,effort=high]", + "name": "claude-opus-4-6" + }, + { + "modelId": "claude-opus-4-5[thinking=true]", + "name": "claude-opus-4-5" + }, + { + "modelId": "gpt-5.2[reasoning=medium,fast=false]", + "name": "gpt-5.2" + }, + { + "modelId": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "name": "gpt-5.6-luna" + }, + { + "modelId": "gemini-3.6-flash[effort=high]", + "name": "gemini-3.6-flash" + }, + { + "modelId": "gemini-3.1-pro[]", + "name": "gemini-3.1-pro" + }, + { + "modelId": "gpt-5.4-mini[reasoning=medium]", + "name": "gpt-5.4-mini" + }, + { + "modelId": "gpt-5.4-nano[reasoning=medium]", + "name": "gpt-5.4-nano" + }, + { + "modelId": "claude-haiku-4-5[thinking=true]", + "name": "claude-haiku-4-5" + }, + { + "modelId": "claude-sonnet-4-5[thinking=true,context=200k]", + "name": "claude-sonnet-4-5" + }, + { + "modelId": "gpt-5.1[reasoning=medium]", + "name": "gpt-5.1" + }, + { + "modelId": "gemini-3-flash[]", + "name": "gemini-3-flash" + }, + { + "modelId": "gemini-3.5-flash[]", + "name": "gemini-3.5-flash" + }, + { + "modelId": "claude-sonnet-4[thinking=false,context=200k]", + "name": "claude-sonnet-4" + }, + { + "modelId": "gpt-5-mini[]", + "name": "gpt-5-mini" + }, + { + "modelId": "gemini-2.5-flash[]", + "name": "gemini-2.5-flash" + }, + { + "modelId": "kimi-k3[reasoning=max]", + "name": "kimi-k3" + }, + { + "modelId": "kimi-k2.7-code[]", + "name": "kimi-k2.7-code" + }, + { + "modelId": "glm-5.2[reasoning=high]", + "name": "glm-5.2" + } + ], + "selectedModel": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "setModelSucceeded": true, + "returnedConfiguration": [ + { + "id": "mode", + "name": "Mode", + "description": "Controls how the agent executes tasks", + "category": "mode", + "type": "select", + "currentValue": "agent", + "options": [ + { + "value": "agent", + "name": "Agent", + "description": "Full agent capabilities with tool access" + }, + { + "value": "plan", + "name": "Plan", + "description": "Read-only mode for planning and designing before implementation" + }, + { + "value": "ask", + "name": "Ask", + "description": "Q&A mode - no edits or command execution" + } + ] + }, + { + "id": "model", + "name": "Model", + "description": "Controls which model variant is used for responses", + "category": "model", + "type": "select", + "currentValue": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "options": [ + { + "value": "default[]", + "name": "Auto" + }, + { + "value": "grok-4.7[context=256k,reasoning_effort=high,fast=true]", + "name": "grok-4.7" + }, + { + "value": "grok-4.6[effort=high,fast=true]", + "name": "grok-4.6" + }, + { + "value": "composer-2.5[fast=true]", + "name": "composer-2.5" + }, + { + "value": "claude-opus-5-5[context=300k,effort=medium,fast=false]", + "name": "claude-opus-5-5" + }, + { + "value": "claude-opus-5[thinking=true,context=300k,effort=high,fast=false]", + "name": "claude-opus-5" + }, + { + "value": "claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]", + "name": "claude-opus-4-8" + }, + { + "value": "gpt-5.6-sol[context=272k,reasoning=medium,fast=false]", + "name": "gpt-5.6-sol" + }, + { + "value": "gpt-5.5[context=272k,reasoning=medium,fast=false]", + "name": "gpt-5.5" + }, + { + "value": "claude-fable-5-1[thinking=true,context=300k,effort=high]", + "name": "claude-fable-5-1" + }, + { + "value": "claude-fable-5[thinking=true,context=300k,effort=high]", + "name": "claude-fable-5" + }, + { + "value": "grok-4.5[effort=high,fast=true]", + "name": "grok-4.5" + }, + { + "value": "gemini-3.8-flash[reasoning_effort=high]", + "name": "gemini-3.8-flash" + }, + { + "value": "gemini-3.7-flash[effort=high]", + "name": "gemini-3.7-flash" + }, + { + "value": "muse-spark-1.3[context=300k,effort=high]", + "name": "muse-spark-1.3" + }, + { + "value": "gpt-5.6-terra[context=272k,reasoning=medium,fast=false]", + "name": "gpt-5.6-terra" + }, + { + "value": "claude-sonnet-5-5[context=300k,reasoning_effort=high]", + "name": "claude-sonnet-5-5" + }, + { + "value": "claude-sonnet-5[thinking=true,context=300k,effort=high]", + "name": "claude-sonnet-5" + }, + { + "value": "claude-sonnet-4-6[thinking=true,context=200k,effort=medium]", + "name": "claude-sonnet-4-6" + }, + { + "value": "gpt-5.3-codex[reasoning=medium,fast=false]", + "name": "gpt-5.3-codex" + }, + { + "value": "claude-opus-4-7[thinking=true,context=300k,effort=xhigh,fast=false]", + "name": "claude-opus-4-7" + }, + { + "value": "gpt-5.4[context=272k,reasoning=medium,fast=false]", + "name": "gpt-5.4" + }, + { + "value": "claude-opus-4-6[thinking=true,context=200k,effort=high]", + "name": "claude-opus-4-6" + }, + { + "value": "claude-opus-4-5[thinking=true]", + "name": "claude-opus-4-5" + }, + { + "value": "gpt-5.2[reasoning=medium,fast=false]", + "name": "gpt-5.2" + }, + { + "value": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "name": "gpt-5.6-luna" + }, + { + "value": "gemini-3.6-flash[effort=high]", + "name": "gemini-3.6-flash" + }, + { + "value": "gemini-3.1-pro[]", + "name": "gemini-3.1-pro" + }, + { + "value": "gpt-5.4-mini[reasoning=medium]", + "name": "gpt-5.4-mini" + }, + { + "value": "gpt-5.4-nano[reasoning=medium]", + "name": "gpt-5.4-nano" + }, + { + "value": "claude-haiku-4-5[thinking=true]", + "name": "claude-haiku-4-5" + }, + { + "value": "claude-sonnet-4-5[thinking=true,context=200k]", + "name": "claude-sonnet-4-5" + }, + { + "value": "gpt-5.1[reasoning=medium]", + "name": "gpt-5.1" + }, + { + "value": "gemini-3-flash[]", + "name": "gemini-3-flash" + }, + { + "value": "gemini-3.5-flash[]", + "name": "gemini-3.5-flash" + }, + { + "value": "claude-sonnet-4[thinking=false,context=200k]", + "name": "claude-sonnet-4" + }, + { + "value": "gpt-5-mini[]", + "name": "gpt-5-mini" + }, + { + "value": "gemini-2.5-flash[]", + "name": "gemini-2.5-flash" + }, + { + "value": "kimi-k3[reasoning=max]", + "name": "kimi-k3" + }, + { + "value": "kimi-k2.7-code[]", + "name": "kimi-k2.7-code" + }, + { + "value": "glm-5.2[reasoning=high]", + "name": "glm-5.2" + } + ] + } + ], + "rejectedVariant": { + "model": "grok-4.7[context=256k,reasoning_effort=low,fast=false]", + "error": { + "code": -32602, + "message": "Invalid params", + "data": { + "message": "Invalid model value: grok-4.7[context=256k,reasoning_effort=low,fast=false]" + } + } + } + }, + "parameterizedPicker": { + "clientCapability": { + "_meta": { + "parameterizedModelPicker": true + } + }, + "model": "grok-4.7", + "returnedConfiguration": [ + { + "id": "mode", + "currentValue": "agent" + }, + { + "id": "model", + "currentValue": "grok-4.7" + }, + { + "id": "context", + "currentValue": "256k" + }, + { + "id": "reasoning_effort", + "currentValue": "low" + }, + { + "id": "fast", + "currentValue": "false" + } + ], + "paperclipSupport": "Not exposed; separate model and parameter identity verification required" + }, + "redactions": [ + "All session IDs, private filesystem paths, credential values, account identity and raw stderr excluded" + ], + "qualification": { + "inferenceEntitlement": "unverified", + "runnerExecution": "pending shared sidecar admission fix", + "daytona": "not run", + "modelCosts": "no model prompt sent" + } +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/authenticated-qualification-attempts.json b/packages/paperclip-runner/test/fixtures/cursor-acp/authenticated-qualification-attempts.json new file mode 100644 index 00000000000..1656efbd574 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/authenticated-qualification-attempts.json @@ -0,0 +1,922 @@ +{ + "schema": "paperclip-cursor-authenticated-qualification/v1", + "providerVersion": "2026.09.26-dd393fe", + "qualification": "pending", + "attempts": [ + { + "id": "cursor-tracer-20260928-1920", + "accountContext": "previous free account; superseded by explicit operator account selection", + "case": "get-task-context", + "runnerSourceRevision": "e5159fb67ad28bf6a691bfa64ffddb332d96f8be", + "providerPackDigest": "sha256:86b907fe5efadaab9bb0953acd1434bcd0e098b1f78bbb2fd7c5ea1b975da5ac", + "runnerPackageSha256": "90c806623d24694997d9d0173cc6c020b1db18e5a978824e2c6f2d0a25825a3b", + "runnerdSha256": "66122cae0160bfa2655d64afb7c7b5c01aa5fa7bba8ad608c7ee768c14f51fec", + "evalRevision": "63e33f556d933beb274447b9568b185a1e37056f", + "requestedModel": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "observedModel": { + "id": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "provider": "cursor" + }, + "startedAt": "2026-09-28T19:17:48.658Z", + "finishedAt": "2026-09-28T19:18:12.924Z", + "durationMs": 24256, + "limits": { + "turnTimeoutMs": 60000, + "maxAgentTurns": 1, + "maxEstimatedCostNanodollars": 500000000 + }, + "automaticRetries": 0, + "nativeTerminalStatus": "completed", + "assistantText": [ + "Upgrade your plan to continue" + ], + "semanticToolInvocations": 0, + "runnerResult": "eval_orchestration_failure", + "runnerDiagnostic": "capability_live_usage_missing", + "interpretation": "Native entitlement denial text was followed by normal ACP completion; missing usage then masked the semantic failure. This attempt is not a pass.", + "networkEvidence": { + "childPaperclipEnvironmentKeys": [], + "realPaperclipRequests": 0 + }, + "cleanup": { + "runnerExited": true, + "runnerExitCode": 0, + "authorityCleared": true, + "mockStopped": true + }, + "usage": { + "submittedPrompts": 1, + "billedPrompts": null, + "tokens": null, + "costUsd": null, + "coverage": "unavailable", + "dashboardBefore": { + "onDemandUsd": 0, + "includedPoolPercent": [ + 0, + 0, + 0 + ] + }, + "dashboardAfter": { + "onDemandUsd": 0, + "includedPoolPercent": [ + 0, + 0, + 0 + ] + }, + "dashboardLimitation": "Aggregate display unchanged at coarse precision; this does not prove zero metered usage or zero charge." + } + }, + { + "id": "cursor-new-account-first-20260928", + "accountContext": "operator-selected replacement Pro+ account", + "case": "get-task-context", + "runnerSourceRevision": "01959b8a602683f13706807983f02c3cba9d36a0", + "providerPackDigest": "sha256:f0b622e9151c1c0886e6220c48ea71993b65887baa88b1600b27462074748ddb", + "runnerPackageSha256": "75ddd8bf9dd474a01df0879ea1ceda6d015e08a3364dc21c38ddfc9e65a60ec0", + "runnerdSha256": "986060810ba7377c6ddd64a5d89e322d0a434e1a9c80400e6d4acf5934bfc421", + "runnerdSourceRevision": "f77ae83aeb6d802c73f1c955760017ae272eba29", + "evalRevision": "b01676fdb7a2783f7b3635a90ba9583041e86338", + "requestedModel": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "observedModel": { + "id": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "provider": "cursor" + }, + "startedAt": "2026-09-28T19:28:46.976Z", + "finishedAt": "2026-09-28T19:29:16.268Z", + "durationMs": 29291, + "limits": { + "turnTimeoutMs": 60000, + "maxAgentTurns": 1, + "maxEstimatedCostNanodollars": 500000000 + }, + "automaticRetries": 0, + "nativeTerminalStatus": "completed", + "assistantText": "I\u2019m assigned PAP-101, \u201cAdd task-context orientation eval,\u201d under PAP-100.\n\nThe immediate next step is to inspect the runner/evaluation harness and implement the smallest end-to-end check proving an agent reads its assigned task context before acting, while preserving inspectable evidence. The manager specifically asked to start with that smallest check. No task completion or blocking action is authorized yet.", + "semanticResults": [ + { + "operationId": "get_task_context", + "ok": true + }, + { + "operationId": "get_task_history", + "ok": true + } + ], + "runnerResult": "passed", + "oracleChecks": [ + { + "id": "context-call", + "passed": true + }, + { + "id": "terminal-completed", + "passed": true + }, + { + "id": "transcript-complete", + "passed": true + }, + { + "id": "mock-only", + "passed": true + } + ], + "scoreDigest": "sha256:0e39e3067a928b9ff4da7a70426f2e643bf9e869bbee514de8dd6a3661bc0942", + "networkEvidence": { + "childPaperclipEnvironmentKeys": [], + "realPaperclipRequests": 0 + }, + "cleanup": { + "runnerExited": true, + "runnerExitCode": 0 + }, + "usage": { + "submittedPrompts": 1, + "nativeReceipt": null, + "tokenUsage": null, + "costUsd": null, + "coverage": "provider usage unavailable; dashboard row attributed" + }, + "billing": { + "source": "parent-observed account Usage row after bounded attempt", + "observedRequestAt": "2026-09-28T19:28:55Z", + "modelLabel": "gpt-5.6-luna-medium", + "tokensDisplay": "56K", + "usageType": "Included", + "chargeDisplay": "Included", + "incrementalCashChargeUsd": 0, + "meteredCostUsd": null, + "conservativeListCostUpperBoundUsd": 0.07, + "listCostBoundBasis": "At most 57,000 rounded-display tokens at the maximum listed $1.20 per million token rate; this is not a billing receipt." + }, + "scope": "Runner Eval: authenticated local native-MCP semantic tools on mock control plane; not Product E2E or native interaction/recovery/Daytona qualification" + }, + { + "id": "cursor-product-hello-20260928", + "family": "product-e2e", + "case": "extended-harnesses.runner-acpx-cursor.local.hello-complete", + "runnerSourceRevision": "66ddbc1f9e2001e95dd8a32bd1da09e65df363f7", + "startedAt": "2026-09-28T19:37:31.806Z", + "finishedAt": "2026-09-28T19:37:32.243Z", + "result": "failed_before_agent_creation", + "failureClass": "permanent_infrastructure", + "errorCode": "paperclip_runner_acpx_agent_unavailable", + "submittedPrompts": 0, + "providerRuns": 0, + "automaticRetries": 0, + "cleanup": "passed", + "interpretation": "Public agent creation rejected the pending candidate before provider launch; foundation 26dde3cfe added exact host-owned candidate/model qualification admission. A separately authorized replacement retained the failure." + }, + { + "id": "cursor-product-hello-replacement-20260928", + "family": "product-e2e", + "case": "extended-harnesses.runner-acpx-cursor.local.hello-complete", + "runnerSourceRevision": "edf538e61e712dddb6b4d59045c3dcfd445686c7", + "suiteDefinitionHash": "e75cb2222d2f52ec3a2a425211d195d36b95882de2cb9ed0ad93231b7c42768a", + "runnerdSha256": "986060810ba7377c6ddd64a5d89e322d0a434e1a9c80400e6d4acf5934bfc421", + "runnerdSourceRevision": "f77ae83aeb6d802c73f1c955760017ae272eba29", + "requestedModel": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "startedAt": "2026-09-28T19:41:56.376Z", + "finishedAt": "2026-09-28T19:42:38.761Z", + "durationMs": 42385, + "providerStartedAt": "2026-09-28T19:42:03.686Z", + "providerFinishedAt": "2026-09-28T19:42:36.720Z", + "providerTimeoutSec": 120, + "timeoutFired": false, + "result": "passed", + "automaticRetries": 0, + "submittedPrompts": 1, + "providerRuns": 1, + "matchers": [ + { + "kind": "message_exact", + "passed": true + }, + { + "kind": "message_occurrences", + "passed": true + }, + { + "kind": "issue_status", + "passed": true + }, + { + "kind": "run_status", + "passed": true + }, + { + "kind": "runtime_mode", + "passed": true + }, + { + "kind": "environment", + "passed": true + } + ], + "invariantFailures": [], + "taskStatus": "done", + "runStatus": "succeeded", + "finalizationPhase": "committed", + "cleanup": "passed", + "resultSha256": "5e1ac034a851b0ea124ca314c67e16a6e562ca6e54a30c4421daca1459ccfcd9", + "screenshot": { + "id": "final-state", + "sha256": "04f163928e8f43a1216bc432c4be5248d2434070d270eff9274beaa76730e67a", + "visuallyInspected": true, + "observed": "Task Done; Cursor assignee; one completion marker; tool activity visible." + }, + "usage": { + "nativeReceipt": null, + "tokenUsage": null, + "costUsd": null, + "coverage": "provider usage unavailable; dashboard row attributed" + }, + "scope": "Real local browser/server/database/native runner and authenticated completion tool. Does not qualify native questions/plans, restrictive permissions, file artifacts, recovery, or Daytona.", + "billing": { + "source": "parent-observed account Usage row after bounded attempt", + "observedRequestAt": "2026-09-28T19:42:25Z", + "modelLabel": "gpt-5.6-luna-medium", + "tokensDisplay": "41.7K", + "usageType": "Included in Pro Plus", + "chargeDisplay": "Included", + "incrementalCashChargeUsd": 0, + "meteredCostUsd": null, + "conservativeListCostUpperBoundUsd": 0.06, + "listCostBoundBasis": "At most 42,000 rounded-display tokens at the maximum listed $1.20 per million token rate, rounded up to $0.06; not a billing receipt." + } + }, + { + "id": "cursor-product-file-edit-20260928", + "family": "product-e2e", + "case": "extended-harnesses.runner-acpx-cursor.local.file-edit-validate", + "runnerSourceRevision": "fe132224c2b30a8d9ce7b46cea38b8760af233fc", + "suiteDefinitionHash": "e75cb2222d2f52ec3a2a425211d195d36b95882de2cb9ed0ad93231b7c42768a", + "runnerdSha256": "986060810ba7377c6ddd64a5d89e322d0a434e1a9c80400e6d4acf5934bfc421", + "requestedModel": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "startedAt": "2026-09-28T19:44:43.582Z", + "finishedAt": "2026-09-28T19:45:24.406Z", + "durationMs": 40824, + "providerStartedAt": "2026-09-28T19:44:48.263Z", + "providerFinishedAt": "2026-09-28T19:45:20.690Z", + "providerTimeoutSec": 120, + "timeoutFired": false, + "result": "passed", + "automaticRetries": 0, + "submittedPrompts": 1, + "providerRuns": 1, + "matchers": [ + { + "kind": "message_exact", + "passed": true + }, + { + "kind": "message_occurrences", + "passed": true + }, + { + "kind": "issue_status", + "passed": true + }, + { + "kind": "run_status", + "passed": true + }, + { + "kind": "runtime_mode", + "passed": true + }, + { + "kind": "environment", + "passed": true + }, + { + "kind": "file_exact", + "passed": true + } + ], + "invariantFailures": [], + "taskStatus": "done", + "runStatus": "succeeded", + "cleanup": "passed", + "resultSha256": "2554fd5709ce35712a3f23405fe7550d169b9f3a0cde2a9906c18e4f7ba4ae86", + "screenshot": { + "id": "final-state", + "sha256": "712a1b2105ff54530db9d2a7ff3764c53ad9cbf2bbe047e90010e8f44563972c", + "visuallyInspected": true, + "observed": "Task Done; Cursor assignee; 24-byte text file and downloadable artifact card; Used tools, edited files; one completion marker." + }, + "fileEvidence": { + "exactFinalBytesMatched": true, + "sizeBytes": 24, + "sha256": "a6a152b61f60708dc2982f1a0c06cb8dd7ba6339d26018a0f00a072534fd8c18", + "commandValidation": "Python compared actual bytes to the expected bytes and exited successfully.", + "workspaceArtifactPresented": true, + "providerDiffProjectionQualified": false + }, + "observedEventGap": { + "field": "native command rawOutput.exitCode", + "nativeValue": 0, + "canonicalExitCode": null, + "preservedAs": "serialized rawOutput text", + "followUp": "P2: validated numeric command result projection; do not infer from arbitrary output text." + }, + "usage": { + "nativeReceipt": null, + "tokenUsage": null, + "costUsd": null, + "coverage": "provider usage unavailable; dashboard row attributed" + }, + "scope": "Real local create/edit/command-validation plus independent byte matcher and visible workspace artifact. Does not qualify provider-native diff projection, restrictive denial, native input, recovery, or Daytona.", + "billing": { + "source": "parent-observed account Usage row after bounded attempt", + "observedRequestAt": "2026-09-28T19:44:59Z", + "modelLabel": "gpt-5.6-luna-medium", + "tokensDisplay": "84.1K", + "usageType": "Included in Pro Plus", + "chargeDisplay": "Included", + "incrementalCashChargeUsd": 0, + "meteredCostUsd": null, + "conservativeListCostUpperBoundUsd": 0.102, + "listCostBoundBasis": "Rounded dashboard token count bounded conservatively at the maximum listed $1.20 per million token rate; not a billing receipt." + } + }, + { + "id": "cursor-product-question-merged-20260928", + "family": "product-e2e", + "case": "extended-harnesses.runner-acpx-cursor.local.question-resume-complete", + "runnerSourceRevision": "a7e01a0cec397dd5048f5d5b5825658dc6e91450", + "suiteDefinitionHash": "e75cb2222d2f52ec3a2a425211d195d36b95882de2cb9ed0ad93231b7c42768a", + "packSourceRevision": "19ac45c4fd87b8b9f28507b3ecd57803da3f7c73", + "providerPackDigest": "sha256:f9b1869c3d6822981518a4acfa5ffd0d8623ac555034754d92e57dabd56feadb", + "runnerdSha256": "dadafb10a6a02f2046798ae4104edc481d205e65724e0526a7f34ba50eb746d0", + "runnerdSourceRevision": "527ac0e31f45a613cba1f65c25b01547f176736d", + "requestedModel": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "startedAt": "2026-09-28T20:04:44.147Z", + "finishedAt": "2026-09-28T20:05:52.691Z", + "durationMs": 68544, + "providerSubmittedAt": [ + "2026-09-28T20:05:00.738Z", + "2026-09-28T20:05:30.525Z" + ], + "providerTimeoutSec": 120, + "outerTimeoutSec": 300, + "result": "passed", + "automaticRetries": 0, + "submittedPrompts": 2, + "providerRuns": 2, + "matchers": [ + { + "kind": "message_exact", + "passed": true + }, + { + "kind": "message_occurrences", + "passed": true + }, + { + "kind": "issue_status", + "passed": true + }, + { + "kind": "run_status", + "passed": true + }, + { + "kind": "runtime_mode", + "passed": true + }, + { + "kind": "environment", + "passed": true + } + ], + "invariantFailures": [], + "taskStatus": "done", + "cleanup": "passed", + "resultSha256": "957fac7017a0059bbd222afa7e14b080fdea2e1c2409af5a6f0d852191ba7691", + "screenshots": [ + { + "id": "question-pending", + "sha256": "6ac5022e20d275b1746a3b038dcf3949653f053523fccb395163b1cbc1766713", + "visuallyInspected": true + }, + { + "id": "final-state", + "sha256": "2dc98776e6178c8f8a494ebb4bb66938fbfee2a41aa43b67e053b0bd8838fdc1", + "visuallyInspected": true + } + ], + "questionEvidence": { + "origin": "Paperclip request_human_input semantic tool", + "kind": "ask_user_questions", + "selectionMode": "single", + "pendingBeforeAnswer": true, + "requestedAt": "2026-09-28T20:05:10.459Z", + "resolvedAt": "2026-09-28T20:05:16.215Z", + "answer": "Cobalt", + "finalStatus": "answered", + "serverRestartedBeforeAnswer": false, + "continuationRunObserved": true, + "nativeCursorExtensionQualified": false + }, + "usage": { + "nativeReceipt": null, + "tokenUsage": null, + "costUsd": null, + "coverage": "provider usage unavailable; two dashboard rows attributed" + }, + "scope": "Real local browser question, answer, wake-assignee continuation and completion. Does not qualify cursor/ask_question native RPC, native plan decisions, restrictive permissions, restart recovery, or Daytona.", + "billing": { + "source": "parent-observed account Usage rows after bounded case", + "requests": [ + { + "at": "2026-09-28T20:05:01Z", + "tokensDisplay": "35.5K" + }, + { + "at": "2026-09-28T20:05:35Z", + "tokensDisplay": "42K" + } + ], + "modelLabel": "gpt-5.6-luna-medium", + "usageType": "Included in Pro Plus", + "incrementalCashChargeUsd": 0, + "meteredCostUsd": null, + "conservativeListCostUpperBoundUsd": 0.1, + "listCostBoundBasis": "Rounded display total bounded at80,000 tokens, maximum listed $1.20/M, rounded upward; not a billing receipt." + } + }, + { + "id": "cursor-product-plan-merged-20260928", + "family": "product-e2e", + "case": "extended-harnesses.runner-acpx-cursor.local.plan-approve-complete", + "runnerSourceRevision": "a7e01a0cec397dd5048f5d5b5825658dc6e91450", + "suiteDefinitionHash": "e75cb2222d2f52ec3a2a425211d195d36b95882de2cb9ed0ad93231b7c42768a", + "packSourceRevision": "19ac45c4fd87b8b9f28507b3ecd57803da3f7c73", + "providerPackDigest": "sha256:f9b1869c3d6822981518a4acfa5ffd0d8623ac555034754d92e57dabd56feadb", + "runnerdSha256": "dadafb10a6a02f2046798ae4104edc481d205e65724e0526a7f34ba50eb746d0", + "runnerdSourceRevision": "527ac0e31f45a613cba1f65c25b01547f176736d", + "requestedModel": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "startedAt": "2026-09-28T20:09:56.429Z", + "finishedAt": "2026-09-28T20:10:49.114Z", + "durationMs": 52685, + "providerSubmittedAt": [ + "2026-09-28T20:10:09.422Z", + "2026-09-28T20:10:33.832Z" + ], + "providerTimeoutSec": 120, + "outerTimeoutSec": 300, + "result": "passed", + "automaticRetries": 0, + "submittedPrompts": 2, + "providerRuns": 2, + "matchers": [ + { + "kind": "message_exact", + "passed": true + }, + { + "kind": "message_occurrences", + "passed": true + }, + { + "kind": "issue_status", + "passed": true + }, + { + "kind": "run_status", + "passed": true + }, + { + "kind": "runtime_mode", + "passed": true + }, + { + "kind": "environment", + "passed": true + } + ], + "invariantFailures": [], + "taskStatus": "done", + "cleanup": "passed", + "resultSha256": "3bfa8d5f62ae827e73bc0aabb496f8aefdf55338ada76985b9222485d4fd58d2", + "screenshots": [ + { + "id": "plan-pending", + "sha256": "ef025fedfe201da0e2a8e31f8fdd2b14d9d9e4248e2c5f2a310410915e5ea98c", + "visuallyInspected": true + }, + { + "id": "final-state", + "sha256": "bd7ad23c19903a4df63482a1b89642093fc74526fdae3331416986f5a0cd370f", + "visuallyInspected": true + } + ], + "usage": { + "nativeReceipt": null, + "tokenUsage": null, + "costUsd": null, + "coverage": "provider usage unavailable; two dashboard rows attributed" + }, + "scope": "Real local browser Plan display, exact-revision approval, wake-assignee continuation and completion. Does not qualify cursor/create_plan native RPC, restrictive permissions, restart recovery, or Daytona.", + "planEvidence": { + "origin": "Paperclip write_document and request_human_input semantic tools", + "kind": "request_confirmation", + "revisionNumber": 1, + "pendingBeforeApproval": true, + "requestedAt": "2026-09-28T20:10:21.718Z", + "resolvedAt": "2026-09-28T20:10:25.286Z", + "finalStatus": "accepted", + "confirmationTargetsDisplayedRevision": true, + "continuationRunObserved": true, + "nativeCursorExtensionQualified": false + }, + "billing": { + "source": "parent-observed account Usage rows after bounded case", + "requests": [ + { + "at": "2026-09-28T20:10:09Z", + "tokensDisplay": "48.5K" + }, + { + "at": "2026-09-28T20:10:34Z", + "tokensDisplay": "43.7K" + } + ], + "modelLabel": "gpt-5.6-luna-medium", + "usageType": "Included in Pro Plus", + "incrementalCashChargeUsd": 0, + "meteredCostUsd": null, + "conservativeListCostUpperBoundUsd": 0.114, + "listCostBoundBasis": "Rounded display total bounded at 95,000 tokens at the maximum listed $1.20/M; not a billing receipt." + } + }, + { + "id": "cursor-product-question-restart-merged-20260928", + "family": "product-e2e", + "case": "extended-harnesses.runner-acpx-cursor.local.structured-question-restart-resume", + "runnerSourceRevision": "a7e01a0cec397dd5048f5d5b5825658dc6e91450", + "requestedModel": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "startedAt": "2026-09-28T20:12:21.993813+00:00", + "finishedAt": "2026-09-28T20:12:22.080838+00:00", + "result": "failed_before_harness_start", + "failureClass": "local_launcher_infrastructure", + "errorCode": "tsx_ipc_socket_path_einval", + "submittedPrompts": 0, + "providerRuns": 0, + "automaticRetries": 0, + "processCleanupVerified": true, + "billing": { + "incrementalCashChargeUsd": 0, + "evidence": "tsx failed to bind startup Unix socket before loading Product harness; no provider process or prompt." + }, + "scope": "Task-owned temporary Unix socket path exceeded macOS length limit; no model or product behavior was measured." + }, + { + "id": "cursor-restart-20260928", + "family": "product-e2e", + "case": "extended-harnesses.runner-acpx-cursor.local.structured-question-restart-resume", + "runnerSourceRevision": "a7e01a0cec397dd5048f5d5b5825658dc6e91450", + "suiteDefinitionHash": "e75cb2222d2f52ec3a2a425211d195d36b95882de2cb9ed0ad93231b7c42768a", + "packSourceRevision": "19ac45c4fd87b8b9f28507b3ecd57803da3f7c73", + "providerPackDigest": "sha256:f9b1869c3d6822981518a4acfa5ffd0d8623ac555034754d92e57dabd56feadb", + "runnerdSha256": "dadafb10a6a02f2046798ae4104edc481d205e65724e0526a7f34ba50eb746d0", + "requestedModel": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "startedAt": "2026-09-28T20:13:24.214Z", + "finishedAt": "2026-09-28T20:14:00.389Z", + "durationMs": 36175, + "result": "failed", + "failureClass": "transient_infrastructure", + "errorCode": "embedded_postgres_semaphore_exhaustion", + "automaticRetries": 0, + "authorizedInfrastructureRetryOf": "cursor-product-question-restart-merged-20260928", + "providerTimeoutSec": 120, + "outerTimeoutSec": 300, + "submittedPrompts": 1, + "providerRuns": 1, + "promptCountEvidence": "Pending question screenshot and graceful shutdown with no interrupted active runs; replacement server failed before an answer or continuation. Post-failure API snapshot could not recover exact submission time.", + "cleanup": "failed", + "processCleanupVerifiedSeparately": true, + "cleanupEvidence": "Original cleanup API check failed because server was down; subsequent task-path process inspection and replacement PostgreSQL PID check found no surviving owned processes.", + "resultSha256": "c74c3f5e96ffc7940d7d87ca05b7ff77d7cf9912a87ec340c9db38e19a06fcfd", + "screenshots": [ + { + "id": "question-pending", + "sha256": "f575e57fc975175d7122893f3bf1110195b0dd2a68b82c5101838177932dd8d7", + "visuallyInspected": true + } + ], + "questionEvidence": { + "origin": "Paperclip request_human_input semantic tool", + "pendingQuestionVisible": true, + "options": [ + "Cobalt", + "Amber" + ], + "serverRestartRequested": true, + "replacementServerStarted": false, + "answered": false, + "continuationRunObserved": false, + "nativeCursorExtensionQualified": false + }, + "usage": { + "nativeReceipt": null, + "tokenUsage": null, + "costUsd": null, + "coverage": "provider usage unavailable; one dashboard row attributed" + }, + "scope": "Infrastructure failure after one completed pending-question turn; does not qualify server-restart recovery. Original failed cleanup and evidence retained.", + "billing": { + "source": "parent-observed account Usage row within retained attempt time window", + "requests": [ + { + "at": "2026-09-28T20:13:38Z", + "tokensDisplay": "23.7K" + } + ], + "modelLabel": "gpt-5.6-luna-medium", + "usageType": "Included in Pro Plus", + "incrementalCashChargeUsd": 0, + "meteredCostUsd": null, + "conservativeListCostUpperBoundUsd": 0.03, + "listCostBoundBasis": "At most 25,000 rounded-display tokens at maximum listed $1.20/M; not a billing receipt." + } + }, + { + "schema": "paperclip-cursor-native-question-probe/v1", + "sourceRevision": "25fb1b5b317e52a8ad50208d7681a1ee34bd939c", + "providerPackDigest": "sha256:38bc6dd39c13b0f5478a1018e26deb61b0333ab0242f3d857e3c79d3a34b7682", + "model": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "prompts": 1, + "automaticRetries": 0, + "activeDeadlineSec": 120, + "outerDeadlineSec": 180, + "reservedUsd": 2, + "declaredEnvelopeUsd": 0.5, + "expectedNativeMethod": "cursor/ask_question", + "expectedQuestions": [ + "single Cobalt/Amber", + "multi Cedar/Birch/Maple" + ], + "expectedAnswer": [ + "Cobalt", + [ + "Cedar", + "Maple" + ] + ], + "semanticTools": [], + "nativePlanProbe": "not attempted: runtime lacks explicit session/set_mode", + "scope": "actual native provider -> patched ACPX -> profile normalization -> sidecar runtime input and pipe response acknowledgement; excludes Rust durable replay and browser", + "startedAt": "2026-09-28T20:39:41.855Z", + "status": "failed", + "costUsd": null, + "promptSubmittedAt": "2026-09-28T20:39:47.990Z", + "sessionClosed": true, + "finishedAt": "2026-09-28T20:39:58.232Z", + "inputCount": 0, + "deliveryAcknowledged": false, + "duplicateResolutionRejected": false, + "terminalStatus": "completed", + "nativeUsageReceipt": null, + "id": "cursor-native-question-20260928", + "family": "native-sidecar-qualification", + "case": "cursor/ask_question-single-and-multi", + "failureClass": "provider_behavior", + "oracleFailure": "Expected one native input request; observed zero.", + "visibleProviderResponse": "The native AskQuestion tool is unavailable in this session.", + "probeSha256": "ff197923020be5b578112603980c9aebb0f1c644baffdf7c086bae52998e2c0b", + "resultSha256": "b0674dd97f36ff754875677b5b139fad8af3595455d6321600663b5df0e0775a", + "billing": { + "source": "parent-observed account Usage row within retained probe window", + "requests": [ + { + "at": "2026-09-28T20:39:48Z", + "tokensDisplay": "17.4K" + } + ], + "modelLabel": "gpt-5.6-luna-medium", + "usageType": "Included in Pro Plus", + "incrementalCashChargeUsd": 0, + "meteredCostUsd": null, + "conservativeListCostUpperBoundUsd": 0.024, + "listCostBoundBasis": "At most 20,000 rounded-display tokens at maximum listed $1.20/M; not a billing receipt." + } + }, + { + "id": "cursor-restart-final-20260928", + "family": "product-e2e", + "case": "extended-harnesses.runner-acpx-cursor.local.structured-question-restart-resume", + "runnerSourceRevision": "d35b83074a018537f5475568d7410e3b1d676789", + "suiteDefinitionHash": "e75cb2222d2f52ec3a2a425211d195d36b95882de2cb9ed0ad93231b7c42768a", + "packSourceRevision": "25fb1b5b317e52a8ad50208d7681a1ee34bd939c", + "providerPackDigest": "sha256:38bc6dd39c13b0f5478a1018e26deb61b0333ab0242f3d857e3c79d3a34b7682", + "runnerdSha256": "e6a9fb5170b76a49b8411834b3706e8edf8f1a1ae85ad13b55368158aa7f67a0", + "runnerdSourceRevision": "78360fa55adff11da9f200b228467eca782b81e1", + "requestedModel": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "startedAt": "2026-09-28T20:42:13.120Z", + "finishedAt": "2026-09-28T20:43:10.985Z", + "durationMs": 57865, + "providerSubmittedAt": [ + "2026-09-28T20:42:26.185Z", + "2026-09-28T20:42:53.642Z" + ], + "providerTimeoutSec": 120, + "outerTimeoutSec": 300, + "result": "passed", + "automaticRetries": 0, + "submittedPrompts": 2, + "providerRuns": 2, + "matchers": [ + { + "kind": "message_exact", + "passed": true + }, + { + "kind": "message_occurrences", + "passed": true + }, + { + "kind": "issue_status", + "passed": true + }, + { + "kind": "run_status", + "passed": true + }, + { + "kind": "runtime_mode", + "passed": true + }, + { + "kind": "environment", + "passed": true + } + ], + "invariantFailures": [], + "taskStatus": "done", + "cleanup": "passed", + "resultSha256": "0ca2c23dd458b35ffb7c72f6e7a1855ffc31b990124347805330a34cc365dddc", + "screenshots": [ + { + "id": "question-pending", + "sha256": "8d3d3fe7a3dfd28b5b4a950d12b2be7feb492d9c9b7112bb484f01e65f63c3a4", + "visuallyInspected": true + }, + { + "id": "final-state", + "sha256": "eeb684b5a06f23703c4a76a7c81c05b6d7f640706fd12035e996e5dda1849b21", + "visuallyInspected": true + } + ], + "questionEvidence": { + "origin": "Paperclip request_human_input semantic tool", + "kind": "ask_user_questions", + "selectionMode": "single", + "pendingBeforeAnswer": true, + "requestedAt": "2026-09-28T20:42:32.665Z", + "resolvedAt": "2026-09-28T20:42:51.140Z", + "answer": "Cobalt", + "finalStatus": "answered", + "serverRestartedBeforeAnswer": true, + "continuationRunObserved": true, + "nativeCursorExtensionQualified": false + }, + "usage": { + "nativeReceipt": null, + "tokenUsage": null, + "costUsd": null, + "coverage": "provider usage unavailable; two dashboard rows attributed" + }, + "scope": "Real local browser/server restart while a semantic question is pending, persisted answer, wake-assignee continuation and completion. Does not qualify native cursor/ask_question pending-request restoration, restrictive permissions, or Daytona.", + "authorizedInfrastructureRetryOf": "cursor-restart-20260928", + "processCleanupVerifiedSeparately": true, + "billing": { + "source": "parent-observed account Usage rows within retained retry window", + "requests": [ + { + "at": "2026-09-28T20:42:26Z", + "tokensDisplay": "23.8K" + }, + { + "at": "2026-09-28T20:42:57Z", + "tokensDisplay": "41.7K" + } + ], + "modelLabel": "gpt-5.6-luna-medium", + "usageType": "Included in Pro Plus", + "incrementalCashChargeUsd": 0, + "meteredCostUsd": null, + "conservativeListCostUpperBoundUsd": 0.084, + "listCostBoundBasis": "Rounded display total bounded at 70,000 tokens at maximum listed $1.20/M; not a billing receipt." + } + }, + { + "id": "cursor-native-denial-20260928", + "family": "native-acp-risk-qualification", + "case": "deny-shell-write", + "runnerSourceRevision": "cb63ccd1d0b35f9bd230eafb849cf6750661f809", + "packSourceRevision": "25fb1b5b317e52a8ad50208d7681a1ee34bd939c", + "providerPackDigest": "sha256:38bc6dd39c13b0f5478a1018e26deb61b0333ab0242f3d857e3c79d3a34b7682", + "requestedModel": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "startedAt": "2026-09-28T20:51:59.907Z", + "promptSubmittedAt": "2026-09-28T20:52:07.462Z", + "finishedAt": "2026-09-28T20:52:16.996Z", + "result": "original_grader_failed_offline_trace_passed", + "submittedPrompts": 1, + "automaticRetries": 0, + "providerTimeoutSec": 120, + "outerTimeoutSec": 180, + "originalOracleResult": { + "passed": false, + "failures": [ + "no_observed_delivered_denial_of_write" + ] + }, + "offlineOracleResult": { + "passed": true, + "failures": [] + }, + "originalResultSha256": "84e9b1a0b7f74a5e6fa980c1f0023b8d997f90b1746623db95580f12613c4dd7", + "offlineAssessmentSha256": "9d815c4607d4c33328f393d2eb11d25954fcc16d659f42cd22d2e80e22190f8f", + "permissionEvidence": { + "nativeRequestId": 0, + "offeredOptionIds": [ + "allow-once", + "allow-always", + "reject-once" + ], + "selectedOptionId": "reject-once", + "responseDelivered": true, + "exactCommandBoundBySameSessionToolCallId": true, + "markerSamples": 98, + "markerEverExists": false + }, + "cleanup": "passed", + "usage": { + "nativeReceipt": null, + "tokenUsage": null, + "costUsd": null, + "coverage": "native usage unavailable; one dashboard row attributed" + }, + "scope": "verified native ACP lease; excludes sidecar normalization, Rust durability, and Product approval UI", + "billing": { + "source": "parent-observed account Usage row within retained probe window", + "requests": [ + { + "at": "2026-09-28T20:52:07Z", + "tokensDisplay": "24.4K" + } + ], + "modelLabel": "gpt-5.6-luna-medium", + "usageType": "Included in Pro Plus", + "incrementalCashChargeUsd": 0, + "meteredCostUsd": null, + "conservativeListCostUpperBoundUsd": 0.03, + "listCostBoundBasis": "At most 25,000 rounded-display tokens at maximum listed $1.20/M; not a billing receipt." + } + } + ], + "newAccountDiscovery": { + "accountContext": "operator-selected replacement account; Pro+ confirmed by operator dashboard", + "methods": [ + { + "method": "initialize", + "status": "success" + }, + { + "method": "cursor/list_available_models", + "status": "success" + }, + { + "method": "session/new", + "status": "success" + }, + { + "method": "session/set_model", + "status": "success" + }, + { + "method": "session/set_config_option", + "status": "success" + } + ], + "modelCount": 41, + "requestedModel": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "exactModelEcho": true, + "modelPrompts": 0 + }, + "nativeWrapperGap": { + "source": "Pinned 5672.index.js entitlement catch", + "behavior": "Actionable upgrade, login, payment, and configuration errors become assistant message text without structured failure metadata; ACP completion may still be successful.", + "treatment": "Retain raw outcome for qualification and semantic oracle; no string-only terminal heuristic.", + "priority": "P1", + "followUp": "Upstream typed failure or an owned verified wrapper hook that preserves typed error before it is rendered." + }, + "redactions": [ + "No tokens, account identifiers, provider session identifiers, private paths, or hidden reasoning retained." + ] +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/credential-free-probe.json b/packages/paperclip-runner/test/fixtures/cursor-acp/credential-free-probe.json new file mode 100644 index 00000000000..37d7f4675c9 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/credential-free-probe.json @@ -0,0 +1,152 @@ +{ + "version": "2026.09.26-dd393fe", + "requests": [ + { + "method": "initialize", + "params": { + "protocolVersion": 1, + "clientCapabilities": { + "fs": { + "readTextFile": false, + "writeTextFile": false + }, + "terminal": false, + "_meta": { + "subagents": true + } + }, + "clientInfo": { + "name": "paperclip-qualification-probe", + "version": "1" + } + } + }, + { + "method": "session/list", + "params": {} + }, + { + "method": "session/fork", + "params": { + "sessionId": "probe" + } + }, + { + "method": "session/resume", + "params": { + "sessionId": "probe", + "cwd": "/qualification/workspace", + "mcpServers": [] + } + }, + { + "method": "_cursor/list_available_models", + "params": {} + }, + { + "method": "session/new", + "params": { + "cwd": "/qualification/workspace", + "mcpServers": [] + } + } + ], + "responses": [ + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "mcpCapabilities": { + "http": true, + "sse": true + }, + "promptCapabilities": { + "audio": false, + "embeddedContext": false, + "image": true + }, + "sessionCapabilities": { + "list": {}, + "subagents": {} + } + }, + "authMethods": [ + { + "id": "cursor_login", + "name": "Cursor Login", + "description": "Authenticate using existing Cursor login credentials. Run 'agent login' first if not logged in." + } + ] + } + }, + { + "jsonrpc": "2.0", + "id": 2, + "error": { + "code": -32000, + "message": "Authentication required", + "data": { + "message": "Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'." + } + } + }, + { + "jsonrpc": "2.0", + "id": 3, + "error": { + "code": -32601, + "message": "\"Method not found\": session/fork", + "data": { + "method": "session/fork" + } + } + }, + { + "jsonrpc": "2.0", + "id": 4, + "error": { + "code": -32601, + "message": "\"Method not found\": session/resume", + "data": { + "method": "session/resume" + } + } + }, + { + "jsonrpc": "2.0", + "id": 5, + "error": { + "code": -32601, + "message": "\"Method not found\": _cursor/list_available_models", + "data": { + "method": "_cursor/list_available_models" + } + } + }, + { + "jsonrpc": "2.0", + "id": 6, + "error": { + "code": -32000, + "message": "Authentication required", + "data": { + "message": "Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'." + } + } + } + ], + "provenance": { + "capturedAt": "2026-09-28", + "platform": "darwin-arm64", + "archiveSha256": "538827d96a779bab854a865c8e42859e8e87db34b5f69770261b90fc8cfff191", + "billablePrompts": 0, + "auth": "no credentials; private home; in-memory credential store", + "capture": "spawn bundled node with index.js --disable-auto-update acp; newline JSON-RPC; sequential response collection", + "redactions": [ + "private temporary workspace path replaced with /qualification/workspace" + ] + } +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/instructions-v4-offline-proof.json b/packages/paperclip-runner/test/fixtures/cursor-acp/instructions-v4-offline-proof.json new file mode 100644 index 00000000000..e02fb16b25d --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/instructions-v4-offline-proof.json @@ -0,0 +1,44 @@ +{ + "schema": "paperclip.cursor.instructions-proof.v1", + "providerCalls": 0, + "qualification": "offline-only", + "profileDigest": "sha256:b1440d559ebc4eef5c7a582f1c81fc153270cfbafa1731a8ee76d83713bdf61b", + "materialization": [ + { + "version": "2026.09.26-dd393fe", + "patchVersion": "paperclip-cursor-instructions-v2", + "platform": "darwin", + "architecture": "arm64", + "archiveSha256": "538827d96a779bab854a865c8e42859e8e87db34b5f69770261b90fc8cfff191", + "closureSha256": "912a37edb67fd7737809c24049dce2db20b1330f98b15a6e43809f0e0943cb6c" + }, + { + "version": "2026.09.26-dd393fe", + "patchVersion": "paperclip-cursor-instructions-v2", + "platform": "darwin", + "architecture": "x64", + "archiveSha256": "ed1771c44cbf0f8059c67cac0d939e6a60eb730bd066d7dc4cbb14de314cfb6e", + "closureSha256": "322f54c7bd533b202a311ce8e2c8916bfe0202790ef8a40fbb36bb89cbd506cb" + }, + { + "version": "2026.09.26-dd393fe", + "patchVersion": "paperclip-cursor-instructions-v2", + "platform": "linux", + "architecture": "x64", + "archiveSha256": "8085fd120f5c71f4eae7fea26a043718e5644e3071e4fab3220a0e58c51f9593", + "closureSha256": "a375fc771dfe86b9b24532f95956ec4b02c5f4f24d80f5db06565160eb3b672c" + } + ], + "checks": { + "actualSdkNewAndLoadAdmission": true, + "actualRuntimeAutomaticReloadAfterProviderDeath": true, + "missingAndMismatchedReloadAcknowledgementRejectBeforePrompt": true, + "trustedSandboxRefreshAndAmbientOverrideExclusion": true, + "terminalDiagnosticsAndRichExtensionsPreserved": true + }, + "limits": [ + "Native source methods use explicit dependency doubles at RequestContext constructor; model semantics are unqualified.", + "No rebuilt provider pack or daemon, authenticated inference, billing mutation, or live promotion.", + "Pinned vendor archive identities are unchanged." + ] +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/linux-image-offline-proof.json b/packages/paperclip-runner/test/fixtures/cursor-acp/linux-image-offline-proof.json new file mode 100644 index 00000000000..c30cda56e4b --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/linux-image-offline-proof.json @@ -0,0 +1,47 @@ +{ + "schema": "paperclip.cursor-linux-image-proof/v1", + "recordedAt": "2026-09-28T21:06:05.761523+00:00", + "imageId": "sha256:5fa7951d1d6dd99305555fe00a5baf2bf5b834d16f021053737301975b93986f", + "execution": "local Docker linux/x64; network disabled", + "verification": "generic profile installation registry, pinned full native closure, retained immutable lease, ACP initialize request ID 0", + "native": { + "provider": "cursor", + "target": { + "platform": "linux", + "architecture": "x64" + }, + "sourceSha": "25fb1b5b317e52a8ad50208d7681a1ee34bd939c", + "packDigest": "sha256:5fab4bc1f0633561b7d7e858a535ebd23faeccedb5f86044df071a3206d949ba", + "candidate": { + "version": "2026.09.26-dd393fe", + "profileDigest": "sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899", + "closureDigest": "sha256:bf04ef8ea6a63191067a6b3e15139aa2c793d8419daab246aa3f0a012e1bbcd9", + "qualification": "pending", + "path": "provider-assets/cursor/linux-x64", + "sha256": "sha256:96fc36b9c1e77f307e487dfcc8d5e7fc2caa7ed68d41e9cb33793c00a8281bdc" + }, + "commandDigest": "sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899", + "runnerdSha256": "5eb3031aed118112caf0b80ea67a6cd01283098e2b4e526ef02d1416d125a1fb", + "protocolVersion": 1, + "nativeInfo": null, + "exitCode": null, + "eofSettled": false, + "prompts": 0, + "network": "none", + "credentials": false + }, + "cleanup": { + "method": "bounded explicit owned process-group termination and native lease close", + "result": "passed", + "evidence": "lead-observed image probe completion; native clean EOF is explicitly not claimed" + }, + "sourceProofSha256": "0d67a62f77b87440c3965426b318e552e5f2956ede62e13e0a28c813d95ca83d", + "qualification": { + "status": "pending", + "daytonaExecution": false, + "authenticatedSession": false, + "modelPrompts": 0, + "modelUsageCostUsd": 0, + "limitation": "Packaging/initialization and explicit cleanup only; no authenticated tools, interaction, recovery, permissions, or paid Daytona qualification." + } +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/mode-v5-offline-proof.json b/packages/paperclip-runner/test/fixtures/cursor-acp/mode-v5-offline-proof.json new file mode 100644 index 00000000000..7cfcae2876a --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/mode-v5-offline-proof.json @@ -0,0 +1,32 @@ +{ + "schema": "paperclip.cursor.native-mode-offline-proof.v1", + "sourceSha256": "c01657c111f65153d7a40a923f01a5a86d393d1cd6893eaa03f71b9604d2af0c", + "rows": [ + { + "selected": "agent", + "nativeMetadata": "default", + "configAcknowledgement": "agent", + "currentModeNotification": "agent", + "reconstructedSessionAcknowledgement": "agent", + "userMessageModeInput": "default" + }, + { + "selected": "plan", + "nativeMetadata": "plan", + "configAcknowledgement": "plan", + "currentModeNotification": "plan", + "reconstructedSessionAcknowledgement": "plan", + "userMessageModeInput": "plan" + }, + { + "selected": "ask", + "nativeMetadata": "search", + "configAcknowledgement": "ask", + "currentModeNotification": "ask", + "reconstructedSessionAcknowledgement": "ask", + "userMessageModeInput": "search" + } + ], + "planHandlerHasModeMutation": false, + "boundary": "pinned native config/session/UserMessage methods; storage, mode enum converter and transport doubled; no inference" +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/native-denial-proof.json b/packages/paperclip-runner/test/fixtures/cursor-acp/native-denial-proof.json new file mode 100644 index 00000000000..0f58b50e46b --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/native-denial-proof.json @@ -0,0 +1,756 @@ +{ + "schema": "paperclip.cursor-native-denial-proof/v1", + "id": "cursor-native-denial-20260928", + "probeSourceRevision": "cb63ccd1d0b35f9bd230eafb849cf6750661f809", + "probeSha256": "895cc154bfdb9ba88d4d1e202bb68db7eec413818d011b9b1a1cdf0ce8ffe395", + "packSourceRevision": "25fb1b5b317e52a8ad50208d7681a1ee34bd939c", + "providerPackDigest": "sha256:38bc6dd39c13b0f5478a1018e26deb61b0333ab0242f3d857e3c79d3a34b7682", + "providerVersion": "2026.09.26-dd393fe", + "profileDigest": "sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899", + "model": "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", + "startedAt": "2026-09-28T20:51:59.907Z", + "promptSubmittedAt": "2026-09-28T20:52:07.462Z", + "terminalAt": "2026-09-28T20:52:11.980Z", + "finishedAt": "2026-09-28T20:52:16.996Z", + "promptRequestsSent": 1, + "activeDeadlineSeconds": 120, + "outerDeadlineSeconds": 180, + "automaticRetries": 0, + "nativePermissionRequestId": 0, + "offeredOptions": [ + { + "optionId": "allow-once", + "name": "Allow once", + "kind": "allow_once" + }, + { + "optionId": "allow-always", + "name": "Allow always", + "kind": "allow_always" + }, + { + "optionId": "reject-once", + "name": "Reject", + "kind": "reject_once" + } + ], + "selectedOptionId": "reject-once", + "permissionResponseDeliveredAt": "2026-09-28T20:52:10.919Z", + "originalOracleResult": { + "passed": false, + "failures": [ + "no_observed_delivered_denial_of_write" + ] + }, + "offlineOracleResult": { + "passed": true, + "failures": [] + }, + "assessmentPrompts": 0, + "assessmentMethod": "Correlate prior same-session tool_call rawInput.command with permission toolCallId, exact native reject_once response, delivery acknowledgement, and original independent marker samples.", + "trace": [ + { + "at": "2026-09-28T20:52:10.678Z", + "elapsedMs": 10771, + "direction": "in", + "message": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "fixture-session", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "fixture-tool", + "title": "`printf 'MUST_NOT_EXIST' > qualification-marker.txt`", + "kind": "execute", + "status": "pending", + "rawInput": { + "command": "printf 'MUST_NOT_EXIST' > qualification-marker.txt" + } + } + } + } + }, + { + "at": "2026-09-28T20:52:10.919Z", + "elapsedMs": 11012, + "direction": "in", + "message": { + "jsonrpc": "2.0", + "id": 0, + "method": "session/request_permission", + "params": { + "sessionId": "fixture-session", + "toolCall": { + "toolCallId": "fixture-tool", + "title": "`printf 'MUST_NOT_EXIST' > qualification-marker.txt`", + "kind": "execute", + "status": "pending", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Not in allowlist: printf" + } + } + ] + }, + "options": [ + { + "optionId": "allow-once", + "name": "Allow once", + "kind": "allow_once" + }, + { + "optionId": "allow-always", + "name": "Allow always", + "kind": "allow_always" + }, + { + "optionId": "reject-once", + "name": "Reject", + "kind": "reject_once" + } + ] + } + } + }, + { + "at": "2026-09-28T20:52:10.919Z", + "elapsedMs": 11012, + "direction": "out", + "message": { + "jsonrpc": "2.0", + "id": 0, + "result": { + "outcome": { + "outcome": "selected", + "optionId": "reject-once" + } + } + } + } + ], + "markerSamples": [ + { + "at": "2026-09-28T20:52:00.434Z", + "elapsedMs": 527, + "phase": "before_launch", + "exists": false + }, + { + "at": "2026-09-28T20:52:07.564Z", + "elapsedMs": 7657, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:07.664Z", + "elapsedMs": 7757, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:07.765Z", + "elapsedMs": 7858, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:07.866Z", + "elapsedMs": 7959, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:07.967Z", + "elapsedMs": 8060, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:08.068Z", + "elapsedMs": 8162, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:08.169Z", + "elapsedMs": 8262, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:08.270Z", + "elapsedMs": 8363, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:08.371Z", + "elapsedMs": 8464, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:08.473Z", + "elapsedMs": 8566, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:08.574Z", + "elapsedMs": 8667, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:08.675Z", + "elapsedMs": 8768, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:08.776Z", + "elapsedMs": 8869, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:08.877Z", + "elapsedMs": 8970, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:08.979Z", + "elapsedMs": 9072, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:09.079Z", + "elapsedMs": 9172, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:09.179Z", + "elapsedMs": 9272, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:09.279Z", + "elapsedMs": 9373, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:09.381Z", + "elapsedMs": 9474, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:09.482Z", + "elapsedMs": 9575, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:09.583Z", + "elapsedMs": 9676, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:09.684Z", + "elapsedMs": 9777, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:09.785Z", + "elapsedMs": 9878, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:09.886Z", + "elapsedMs": 9980, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:09.986Z", + "elapsedMs": 10079, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:10.087Z", + "elapsedMs": 10181, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:10.188Z", + "elapsedMs": 10281, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:10.288Z", + "elapsedMs": 10381, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:10.389Z", + "elapsedMs": 10482, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:10.490Z", + "elapsedMs": 10583, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:10.590Z", + "elapsedMs": 10683, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:10.691Z", + "elapsedMs": 10784, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:10.791Z", + "elapsedMs": 10884, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:10.892Z", + "elapsedMs": 10986, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:10.992Z", + "elapsedMs": 11086, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:11.093Z", + "elapsedMs": 11186, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:11.194Z", + "elapsedMs": 11287, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:11.295Z", + "elapsedMs": 11388, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:11.396Z", + "elapsedMs": 11489, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:11.498Z", + "elapsedMs": 11591, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:11.599Z", + "elapsedMs": 11692, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:11.700Z", + "elapsedMs": 11793, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:11.801Z", + "elapsedMs": 11894, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:11.902Z", + "elapsedMs": 11995, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:11.980Z", + "elapsedMs": 12073, + "phase": "terminal", + "exists": false + }, + { + "at": "2026-09-28T20:52:12.002Z", + "elapsedMs": 12096, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:12.103Z", + "elapsedMs": 12196, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:12.203Z", + "elapsedMs": 12296, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:12.304Z", + "elapsedMs": 12397, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:12.405Z", + "elapsedMs": 12498, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:12.506Z", + "elapsedMs": 12599, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:12.607Z", + "elapsedMs": 12700, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:12.708Z", + "elapsedMs": 12801, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:12.808Z", + "elapsedMs": 12902, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:12.909Z", + "elapsedMs": 13002, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:13.010Z", + "elapsedMs": 13103, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:13.111Z", + "elapsedMs": 13204, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:13.212Z", + "elapsedMs": 13305, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:13.313Z", + "elapsedMs": 13406, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:13.414Z", + "elapsedMs": 13507, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:13.515Z", + "elapsedMs": 13608, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:13.616Z", + "elapsedMs": 13710, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:13.717Z", + "elapsedMs": 13810, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:13.818Z", + "elapsedMs": 13911, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:13.918Z", + "elapsedMs": 14011, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:14.019Z", + "elapsedMs": 14113, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:14.120Z", + "elapsedMs": 14213, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:14.220Z", + "elapsedMs": 14313, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:14.321Z", + "elapsedMs": 14414, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:14.422Z", + "elapsedMs": 14516, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:14.524Z", + "elapsedMs": 14617, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:14.625Z", + "elapsedMs": 14718, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:14.726Z", + "elapsedMs": 14819, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:14.827Z", + "elapsedMs": 14920, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:14.928Z", + "elapsedMs": 15022, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:15.029Z", + "elapsedMs": 15122, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:15.130Z", + "elapsedMs": 15223, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:15.230Z", + "elapsedMs": 15324, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:15.331Z", + "elapsedMs": 15424, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:15.432Z", + "elapsedMs": 15525, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:15.532Z", + "elapsedMs": 15625, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:15.633Z", + "elapsedMs": 15727, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:15.734Z", + "elapsedMs": 15827, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:15.835Z", + "elapsedMs": 15928, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:15.936Z", + "elapsedMs": 16029, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:16.037Z", + "elapsedMs": 16130, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:16.138Z", + "elapsedMs": 16232, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:16.239Z", + "elapsedMs": 16332, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:16.340Z", + "elapsedMs": 16433, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:16.441Z", + "elapsedMs": 16535, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:16.542Z", + "elapsedMs": 16635, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:16.643Z", + "elapsedMs": 16736, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:16.744Z", + "elapsedMs": 16837, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:16.845Z", + "elapsedMs": 16938, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:16.945Z", + "elapsedMs": 17039, + "phase": "during_turn", + "exists": false + }, + { + "at": "2026-09-28T20:52:16.980Z", + "elapsedMs": 17073, + "phase": "five_seconds_after_terminal", + "exists": false + }, + { + "at": "2026-09-28T20:52:16.996Z", + "elapsedMs": 17089, + "phase": "after_process_cleanup", + "exists": false + } + ], + "stopReason": "end_turn", + "cleanupComplete": true, + "leaseClosed": true, + "providerExit": { + "code": 143, + "signal": null + }, + "originalResultSha256": "84e9b1a0b7f74a5e6fa980c1f0023b8d997f90b1746623db95580f12613c4dd7", + "offlineAssessmentSha256": "9d815c4607d4c33328f393d2eb11d25954fcc16d659f42cd22d2e80e22190f8f", + "costUsd": null, + "usageReceipt": null, + "scope": "verified native ACP lease; excludes sidecar normalization, Rust durability, and Product approval UI", + "redactions": [ + "Private session and tool IDs replaced consistently; native numeric request ID and option IDs unchanged.", + "No account, credential, hidden reasoning, or private workspace path included." + ], + "billing": { + "source": "parent-observed account Usage row within retained probe window", + "requests": [ + { + "at": "2026-09-28T20:52:07Z", + "tokensDisplay": "24.4K" + } + ], + "modelLabel": "gpt-5.6-luna-medium", + "usageType": "Included in Pro Plus", + "incrementalCashChargeUsd": 0, + "meteredCostUsd": null, + "conservativeListCostUpperBoundUsd": 0.03, + "listCostBoundBasis": "At most 25,000 rounded-display tokens at maximum listed $1.20/M; not a billing receipt." + } +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/native-usage-v3-offline-proof.json b/packages/paperclip-runner/test/fixtures/cursor-acp/native-usage-v3-offline-proof.json new file mode 100644 index 00000000000..d6f945cb901 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/native-usage-v3-offline-proof.json @@ -0,0 +1,56 @@ +{ + "schema": "paperclip.cursor.native-usage-offline-proof.v1", + "providerCalls": 0, + "standardUsageEmitted": false, + "boundary": "digest-pinned handlePrompt, native invocation and child update methods; surrounding session/transport doubled", + "platforms": [ + { + "platform": "darwin-arm64", + "vendorSha256": "7784c8b16d4e639814c13b12be2a687f5dc2cd98cbf29ed0a10820778ab1bf62", + "candidateSha256": "83fa4d462b3d8fd95b05420589cc80b8a6536dbff0cc3c5d36fec69d8a753970", + "checks": [ + "exact_parent_invocation_capture", + "separate_followups", + "no_standard_usage", + "warm_prompt_isolation", + "child_run_attribution", + "history_and_late_child_isolation", + "overlapping_cancellation", + "late_parent_callback_isolation", + "exception_cleanup" + ] + }, + { + "platform": "darwin-x64", + "vendorSha256": "3c0aaf6ecc4fceb0f95e1731deec75384984570d14d0871cecec11972b948ac4", + "candidateSha256": "060c8816fac3d110f5931ce626e5f2d841d8252fba7dafd52e37157ca915301d", + "checks": [ + "exact_parent_invocation_capture", + "separate_followups", + "no_standard_usage", + "warm_prompt_isolation", + "child_run_attribution", + "history_and_late_child_isolation", + "overlapping_cancellation", + "late_parent_callback_isolation", + "exception_cleanup" + ] + }, + { + "platform": "linux-x64", + "vendorSha256": "2de420f1b31e70ca74b083a1ce5b519c2abffa5789d71cc06e84d2c38acb7c07", + "candidateSha256": "3435f90f028eb5b08cf8cb7b289694993636f2868e6af217c167a5fd27ac03b2", + "checks": [ + "exact_parent_invocation_capture", + "separate_followups", + "no_standard_usage", + "warm_prompt_isolation", + "child_run_attribution", + "history_and_late_child_isolation", + "overlapping_cancellation", + "late_parent_callback_isolation", + "exception_cleanup" + ] + } + ] +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/native-usage-v4-offline-proof.json b/packages/paperclip-runner/test/fixtures/cursor-acp/native-usage-v4-offline-proof.json new file mode 100644 index 00000000000..8c2ebf9e725 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/native-usage-v4-offline-proof.json @@ -0,0 +1,59 @@ +{ + "schema": "paperclip.cursor.native-usage-offline-proof.v1", + "providerCalls": 0, + "standardUsageEmitted": false, + "boundary": "digest-pinned handlePrompt, native invocation, child creation/reuse and child update methods; surrounding session/transport doubled", + "platforms": [ + { + "platform": "darwin-arm64", + "vendorSha256": "7784c8b16d4e639814c13b12be2a687f5dc2cd98cbf29ed0a10820778ab1bf62", + "candidateSha256": "35c5bf13b261ea884bb0e7b26ae7bd4c4999b0b4b08ab338bc22f2ccce2b8108", + "checks": [ + "exact_parent_invocation_capture", + "separate_followups", + "no_standard_usage", + "warm_prompt_isolation", + "vendor_child_creation_and_reuse", + "child_run_attribution", + "history_and_late_child_isolation", + "overlapping_cancellation", + "late_parent_callback_isolation", + "exception_cleanup" + ] + }, + { + "platform": "darwin-x64", + "vendorSha256": "3c0aaf6ecc4fceb0f95e1731deec75384984570d14d0871cecec11972b948ac4", + "candidateSha256": "5b2510248429febe1c9aead6ad43d35d89cdedc4dd057d4f07495efa33c7da55", + "checks": [ + "exact_parent_invocation_capture", + "separate_followups", + "no_standard_usage", + "warm_prompt_isolation", + "vendor_child_creation_and_reuse", + "child_run_attribution", + "history_and_late_child_isolation", + "overlapping_cancellation", + "late_parent_callback_isolation", + "exception_cleanup" + ] + }, + { + "platform": "linux-x64", + "vendorSha256": "2de420f1b31e70ca74b083a1ce5b519c2abffa5789d71cc06e84d2c38acb7c07", + "candidateSha256": "38fe96fa71463372bffdd9efa3e5608293ff075d079149fe20a1625783c4eb39", + "checks": [ + "exact_parent_invocation_capture", + "separate_followups", + "no_standard_usage", + "warm_prompt_isolation", + "vendor_child_creation_and_reuse", + "child_run_attribution", + "history_and_late_child_isolation", + "overlapping_cancellation", + "late_parent_callback_isolation", + "exception_cleanup" + ] + } + ] +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/production-v3-offline-build.json b/packages/paperclip-runner/test/fixtures/cursor-acp/production-v3-offline-build.json new file mode 100644 index 00000000000..21ea6f4e5d5 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/production-v3-offline-build.json @@ -0,0 +1,236 @@ +{ + "schema": "paperclip.cursor.production-offline-build.v1", + "observedAt": "2026-09-29", + "runtimeSourceRevision": "526081b9d6c905de5e2da680e3a37c620899b380", + "providerVersion": "2026.09.26-dd393fe", + "profileVersion": 3, + "patchVersion": "paperclip-cursor-isolation-v1", + "providerPackDigest": "sha256:7166a42d06192c53191aad7b15396e291675624001fbd95dd4b3cbda197f87a0", + "candidate": { + "version": "2026.09.26-dd393fe", + "profileDigest": "sha256:c91aa592ec867071ec4457b9b7230399ce42ad918787ceed99b4b651ea5607e2", + "closureDigest": "sha256:8a22016ca832ef8c8e6a83b955208d74c63f2c8dbe1dd688cbf27b81ad01886a", + "qualification": "pending", + "path": "provider-assets/cursor/darwin-arm64", + "sha256": "sha256:e4c656debc32e8a6bf6e6fd7f620223b48ad0b8b18994096d09f056fdf9d809c" + }, + "daemon": { + "sourceRevision": "526081b9d6c905de5e2da680e3a37c620899b380", + "platform": "darwin-arm64", + "sha256": "826611379af7b8148933c58c6c77826c5a4f0773b789aac9eafbbc66b8e3dcb5" + }, + "dependencyProvenance": { + "source": "git archive of exact runtimeSourceRevision", + "isolation": "fresh private pnpm store; copied package files, no shared hardlinks", + "pnpmVersion": "9.15.4", + "committedLockSha256": "e0c928a494f90ddad3c00791e83f09315ee8c82df2a0418a809dcf93649a8ab3", + "stagingLockSha256": "9eea60187c6c808efb4d936a234a8d8000beac45253091abd4c5348132bd1408", + "acpxPatchSha256": "8b698d853fe6df6a34a8f89dddaccc6c8f1e5b7763af5ab5866b77acd42bc127", + "firstFrozenInstall": "failed: ERR_PNPM_LOCKFILE_CONFIG_MISMATCH", + "resolution": "retained original lock, generated private staging lock only, then frozen installation passed; repository lock unchanged" + }, + "checks": { + "runnerTypescriptBuild": "passed", + "runnerReleaseDaemonBuild": "passed", + "freshPackageContracts": { + "passed": 26 + }, + "runtimeControlsAndCursorSdkInteractions": { + "passed": 184, + "scope": "credential-free real ACP SDK fixtures plus mocked admission, recovery, request delivery, lifecycle and cancellation; excludes native provider availability" + }, + "verifiedNativeLease": { + "schema": "paperclip-cursor-offline-pack-proof/v1", + "capturedAt": "2026-09-29T14:50:08.614Z", + "runnerSourceRevision": "526081b9d6c905de5e2da680e3a37c620899b380", + "packDigest": "sha256:7166a42d06192c53191aad7b15396e291675624001fbd95dd4b3cbda197f87a0", + "target": { + "platform": "darwin", + "architecture": "arm64" + }, + "candidate": { + "version": "2026.09.26-dd393fe", + "profileDigest": "sha256:c91aa592ec867071ec4457b9b7230399ce42ad918787ceed99b4b651ea5607e2", + "closureDigest": "sha256:8a22016ca832ef8c8e6a83b955208d74c63f2c8dbe1dd688cbf27b81ad01886a", + "qualification": "pending", + "path": "provider-assets/cursor/darwin-arm64", + "sha256": "sha256:e4c656debc32e8a6bf6e6fd7f620223b48ad0b8b18994096d09f056fdf9d809c" + }, + "productionLock": "sha256:9eea60187c6c808efb4d936a234a8d8000beac45253091abd4c5348132bd1408", + "launch": { + "entrypoint": "verified bundled node + index.js", + "arguments": [ + "--disable-project-configs", + "--disable-auto-update", + "acp" + ], + "credentials": "none; private home/config/data/cache; memory credential store", + "verification": "generic profile installation registry, whole closure digest verification, retained immutable native snapshot, closed module bootstrap", + "modelAdmission": "offline-installation-probe is a factory identity sentinel only; no ACP model selection or prompt; model remains unverified" + }, + "requests": [ + { + "method": "initialize", + "params": { + "protocolVersion": 1, + "clientCapabilities": { + "fs": { + "readTextFile": false, + "writeTextFile": false + }, + "terminal": false, + "_meta": { + "subagents": true + } + }, + "clientInfo": { + "name": "paperclip-offline-pack-probe", + "version": "1" + } + } + }, + { + "method": "cursor/list_available_models", + "params": {} + }, + { + "method": "session/list", + "params": {} + }, + { + "method": "session/fork", + "params": { + "sessionId": "probe" + } + }, + { + "method": "session/resume", + "params": { + "sessionId": "probe", + "cwd": "/qualification/private/workspace", + "mcpServers": [] + } + }, + { + "method": "session/new", + "params": { + "cwd": "/qualification/private/workspace", + "mcpServers": [] + } + } + ], + "responses": [ + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "mcpCapabilities": { + "http": true, + "sse": true + }, + "promptCapabilities": { + "audio": false, + "embeddedContext": false, + "image": true + }, + "sessionCapabilities": { + "list": {}, + "subagents": {} + } + }, + "authMethods": [ + { + "id": "cursor_login", + "name": "Cursor Login", + "description": "Authenticate using existing Cursor login credentials. Run 'agent login' first if not logged in." + } + ] + } + }, + { + "jsonrpc": "2.0", + "id": 2, + "error": { + "code": -32000, + "message": "Authentication required", + "data": { + "message": "Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'." + } + } + }, + { + "jsonrpc": "2.0", + "id": 3, + "error": { + "code": -32000, + "message": "Authentication required", + "data": { + "message": "Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'." + } + } + }, + { + "jsonrpc": "2.0", + "id": 4, + "error": { + "code": -32601, + "message": "\"Method not found\": session/fork", + "data": { + "method": "session/fork" + } + } + }, + { + "jsonrpc": "2.0", + "id": 5, + "error": { + "code": -32601, + "message": "\"Method not found\": session/resume", + "data": { + "method": "session/resume" + } + } + }, + { + "jsonrpc": "2.0", + "id": 6, + "error": { + "code": -32000, + "message": "Authentication required", + "data": { + "message": "Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'." + } + } + } + ], + "process": { + "exitCode": 143, + "signalCode": null, + "leaseClosed": true, + "stderrBytes": 0, + "unexpectedMessageCount": 0 + }, + "qualification": { + "status": "pending", + "model": null, + "billablePrompts": 0, + "costUsd": 0, + "environment": "local macOS ARM64; no Daytona execution" + }, + "redactions": [ + "ephemeral workspace and private directories replaced with /qualification/private" + ] + } + }, + "qualification": { + "status": "pending", + "paidPrompts": 0, + "authenticatedNativeInteractions": "not attempted", + "durableRestrictiveApprovals": "not qualified", + "authenticatedMacosX64": "not qualified", + "authenticatedDaytona": "not qualified" + } +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v10-identity.json b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v10-identity.json new file mode 100644 index 00000000000..d7d2b1e6b06 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v10-identity.json @@ -0,0 +1,59 @@ +{ + "commandDigest": "sha256:1df2a15b93bc3a14fa47fa3315344ba023fe2412048047cdc6f32096a6336564", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "cursor", + "agentProfileVersion": 10, + "acpxVersion": "0.13.1", + "agentServerVersion": "2026.09.26-dd393fe", + "patchVersion": "paperclip-cursor-usage-v4", + "distribution": { + "schema": "paperclip.cursor_distribution.v1", + "version": "2026.09.26-dd393fe", + "platforms": { + "darwin-arm64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/arm64/agent-cli-package.tar.gz", + "archiveSha256": "538827d96a779bab854a865c8e42859e8e87db34b5f69770261b90fc8cfff191", + "closureSha256": "257424bd48e35412091c6adfc61e4648e836757ec1d240d890bba81a24918c30", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25" + }, + "darwin-x64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/x64/agent-cli-package.tar.gz", + "archiveSha256": "ed1771c44cbf0f8059c67cac0d939e6a60eb730bd066d7dc4cbb14de314cfb6e", + "closureSha256": "6f28c799c5afdc64fbdff8a2157f565f17ae7615efe014ac389d63bf70cf2be2", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "7c8775160af74e5fb8d25a30e8412c1a6476b0661529078a686e5118fd1c5e5f" + }, + "linux-x64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/linux/x64/agent-cli-package.tar.gz", + "archiveSha256": "8085fd120f5c71f4eae7fea26a043718e5644e3071e4fab3220a0e58c51f9593", + "closureSha256": "eadb8bb8ffb0450455b15b88c9b230307a9e149958a0c452d16dd157b4633d74", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "bf04ef8ea6a63191067a6b3e15139aa2c793d8419daab246aa3f0a012e1bbcd9" + } + }, + "patchVersion": "paperclip-cursor-usage-v4" + }, + "agentFilesBinding": "registered-runtime-context-v1", + "instructionBinding": "native-global-rules-v1", + "instructionAdmission": "synchronous-protocol-guard-v1", + "acpxPatchSha256": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e", + "sessionModeAdmission": "native-config-ack-recovery-bound-v1", + "sessionModes": [ + "agent", + "plan", + "ask" + ], + "defaultSessionMode": "agent", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativePlanToolIdentity": "request-item-id-bound-lifecycle-v1", + "nativePermissionToolIdentity": "opaque-native-tool-id-sha256-v1", + "toolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", + "toolEvidenceSourceSha256": "8310308ed9605415cb28b7c350d83defef68e90cd30bc2f0491d3d09825e8cd9" + } +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v3-identity.json b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v3-identity.json new file mode 100644 index 00000000000..8389de78c23 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v3-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:c91aa592ec867071ec4457b9b7230399ce42ad918787ceed99b4b651ea5607e2", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "cursor", + "agentProfileVersion": 3, + "acpxVersion": "0.13.1", + "agentServerVersion": "2026.09.26-dd393fe", + "patchVersion": "paperclip-cursor-isolation-v1", + "distribution": { + "schema": "paperclip.cursor_distribution.v1", + "version": "2026.09.26-dd393fe", + "platforms": { + "darwin-arm64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/arm64/agent-cli-package.tar.gz", + "archiveSha256": "538827d96a779bab854a865c8e42859e8e87db34b5f69770261b90fc8cfff191", + "closureSha256": "8a22016ca832ef8c8e6a83b955208d74c63f2c8dbe1dd688cbf27b81ad01886a", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25" + }, + "darwin-x64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/x64/agent-cli-package.tar.gz", + "archiveSha256": "ed1771c44cbf0f8059c67cac0d939e6a60eb730bd066d7dc4cbb14de314cfb6e", + "closureSha256": "2c461f48feb274f8e7aabd6da67d1e9aefbe6e0b232fc0a111dce874378afcc8", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "7c8775160af74e5fb8d25a30e8412c1a6476b0661529078a686e5118fd1c5e5f" + }, + "linux-x64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/linux/x64/agent-cli-package.tar.gz", + "archiveSha256": "8085fd120f5c71f4eae7fea26a043718e5644e3071e4fab3220a0e58c51f9593", + "closureSha256": "4fe9461c19d1aec0cbbe4189159291fe62ef6910732a05fc5a25923afb1ce2c7", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "bf04ef8ea6a63191067a6b3e15139aa2c793d8419daab246aa3f0a012e1bbcd9" + } + }, + "patchVersion": "paperclip-cursor-isolation-v1" + }, + "agentFilesBinding": "registered-runtime-context-v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v4-identity.json b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v4-identity.json new file mode 100644 index 00000000000..fd4f10cf249 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v4-identity.json @@ -0,0 +1,46 @@ +{ + "commandDigest": "sha256:b1440d559ebc4eef5c7a582f1c81fc153270cfbafa1731a8ee76d83713bdf61b", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "cursor", + "agentProfileVersion": 4, + "acpxVersion": "0.13.1", + "agentServerVersion": "2026.09.26-dd393fe", + "patchVersion": "paperclip-cursor-instructions-v2", + "distribution": { + "schema": "paperclip.cursor_distribution.v1", + "version": "2026.09.26-dd393fe", + "platforms": { + "darwin-arm64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/arm64/agent-cli-package.tar.gz", + "archiveSha256": "538827d96a779bab854a865c8e42859e8e87db34b5f69770261b90fc8cfff191", + "closureSha256": "912a37edb67fd7737809c24049dce2db20b1330f98b15a6e43809f0e0943cb6c", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25" + }, + "darwin-x64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/x64/agent-cli-package.tar.gz", + "archiveSha256": "ed1771c44cbf0f8059c67cac0d939e6a60eb730bd066d7dc4cbb14de314cfb6e", + "closureSha256": "322f54c7bd533b202a311ce8e2c8916bfe0202790ef8a40fbb36bb89cbd506cb", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "7c8775160af74e5fb8d25a30e8412c1a6476b0661529078a686e5118fd1c5e5f" + }, + "linux-x64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/linux/x64/agent-cli-package.tar.gz", + "archiveSha256": "8085fd120f5c71f4eae7fea26a043718e5644e3071e4fab3220a0e58c51f9593", + "closureSha256": "a375fc771dfe86b9b24532f95956ec4b02c5f4f24d80f5db06565160eb3b672c", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "bf04ef8ea6a63191067a6b3e15139aa2c793d8419daab246aa3f0a012e1bbcd9" + } + }, + "patchVersion": "paperclip-cursor-instructions-v2" + }, + "agentFilesBinding": "registered-runtime-context-v1", + "instructionBinding": "native-global-rules-v1", + "instructionAdmission": "synchronous-protocol-guard-v1", + "acpxPatchSha256": "64180c194ab841d6f4bba7e6eca5ead621c6222c9a8057e4bb2bb6f007d8bb3c" + } +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v5-identity.json b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v5-identity.json new file mode 100644 index 00000000000..e10e9df1d20 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v5-identity.json @@ -0,0 +1,53 @@ +{ + "commandDigest": "sha256:aa8c0b2b84786982bcd06b7634bf95be6f2bc42bb8def48a8751dc50cf739b6b", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "cursor", + "agentProfileVersion": 5, + "acpxVersion": "0.13.1", + "agentServerVersion": "2026.09.26-dd393fe", + "patchVersion": "paperclip-cursor-instructions-v2", + "distribution": { + "schema": "paperclip.cursor_distribution.v1", + "version": "2026.09.26-dd393fe", + "platforms": { + "darwin-arm64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/arm64/agent-cli-package.tar.gz", + "archiveSha256": "538827d96a779bab854a865c8e42859e8e87db34b5f69770261b90fc8cfff191", + "closureSha256": "912a37edb67fd7737809c24049dce2db20b1330f98b15a6e43809f0e0943cb6c", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25" + }, + "darwin-x64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/x64/agent-cli-package.tar.gz", + "archiveSha256": "ed1771c44cbf0f8059c67cac0d939e6a60eb730bd066d7dc4cbb14de314cfb6e", + "closureSha256": "322f54c7bd533b202a311ce8e2c8916bfe0202790ef8a40fbb36bb89cbd506cb", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "7c8775160af74e5fb8d25a30e8412c1a6476b0661529078a686e5118fd1c5e5f" + }, + "linux-x64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/linux/x64/agent-cli-package.tar.gz", + "archiveSha256": "8085fd120f5c71f4eae7fea26a043718e5644e3071e4fab3220a0e58c51f9593", + "closureSha256": "a375fc771dfe86b9b24532f95956ec4b02c5f4f24d80f5db06565160eb3b672c", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "bf04ef8ea6a63191067a6b3e15139aa2c793d8419daab246aa3f0a012e1bbcd9" + } + }, + "patchVersion": "paperclip-cursor-instructions-v2" + }, + "agentFilesBinding": "registered-runtime-context-v1", + "instructionBinding": "native-global-rules-v1", + "instructionAdmission": "synchronous-protocol-guard-v1", + "acpxPatchSha256": "79aad2d688b03362e8cfcbf7a08f78a8383869f6882a9c9ed66f1d18efb94f2b", + "sessionModeAdmission": "native-config-ack-recovery-bound-v1", + "sessionModes": [ + "agent", + "plan", + "ask" + ], + "defaultSessionMode": "agent" + } +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v6-identity.json b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v6-identity.json new file mode 100644 index 00000000000..55270a55d6a --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v6-identity.json @@ -0,0 +1,54 @@ +{ + "commandDigest": "sha256:377dcea64a727ce799cc112458d4b40ba4bc6574cd6c6f7233b6efd5917a6c4b", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "cursor", + "agentProfileVersion": 6, + "acpxVersion": "0.13.1", + "agentServerVersion": "2026.09.26-dd393fe", + "patchVersion": "paperclip-cursor-instructions-v2", + "distribution": { + "schema": "paperclip.cursor_distribution.v1", + "version": "2026.09.26-dd393fe", + "platforms": { + "darwin-arm64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/arm64/agent-cli-package.tar.gz", + "archiveSha256": "538827d96a779bab854a865c8e42859e8e87db34b5f69770261b90fc8cfff191", + "closureSha256": "912a37edb67fd7737809c24049dce2db20b1330f98b15a6e43809f0e0943cb6c", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25" + }, + "darwin-x64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/x64/agent-cli-package.tar.gz", + "archiveSha256": "ed1771c44cbf0f8059c67cac0d939e6a60eb730bd066d7dc4cbb14de314cfb6e", + "closureSha256": "322f54c7bd533b202a311ce8e2c8916bfe0202790ef8a40fbb36bb89cbd506cb", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "7c8775160af74e5fb8d25a30e8412c1a6476b0661529078a686e5118fd1c5e5f" + }, + "linux-x64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/linux/x64/agent-cli-package.tar.gz", + "archiveSha256": "8085fd120f5c71f4eae7fea26a043718e5644e3071e4fab3220a0e58c51f9593", + "closureSha256": "a375fc771dfe86b9b24532f95956ec4b02c5f4f24d80f5db06565160eb3b672c", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "bf04ef8ea6a63191067a6b3e15139aa2c793d8419daab246aa3f0a012e1bbcd9" + } + }, + "patchVersion": "paperclip-cursor-instructions-v2" + }, + "agentFilesBinding": "registered-runtime-context-v1", + "instructionBinding": "native-global-rules-v1", + "instructionAdmission": "synchronous-protocol-guard-v1", + "acpxPatchSha256": "79aad2d688b03362e8cfcbf7a08f78a8383869f6882a9c9ed66f1d18efb94f2b", + "sessionModeAdmission": "native-config-ack-recovery-bound-v1", + "sessionModes": [ + "agent", + "plan", + "ask" + ], + "defaultSessionMode": "agent", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v7-identity.json b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v7-identity.json new file mode 100644 index 00000000000..7fa530bc6d3 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v7-identity.json @@ -0,0 +1,55 @@ +{ + "commandDigest": "sha256:f4c7af914738149cf868d071e53ac4917658fb055224715a2c89d3f59b335503", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "cursor", + "agentProfileVersion": 7, + "acpxVersion": "0.13.1", + "agentServerVersion": "2026.09.26-dd393fe", + "patchVersion": "paperclip-cursor-instructions-v2", + "distribution": { + "schema": "paperclip.cursor_distribution.v1", + "version": "2026.09.26-dd393fe", + "platforms": { + "darwin-arm64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/arm64/agent-cli-package.tar.gz", + "archiveSha256": "538827d96a779bab854a865c8e42859e8e87db34b5f69770261b90fc8cfff191", + "closureSha256": "912a37edb67fd7737809c24049dce2db20b1330f98b15a6e43809f0e0943cb6c", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25" + }, + "darwin-x64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/x64/agent-cli-package.tar.gz", + "archiveSha256": "ed1771c44cbf0f8059c67cac0d939e6a60eb730bd066d7dc4cbb14de314cfb6e", + "closureSha256": "322f54c7bd533b202a311ce8e2c8916bfe0202790ef8a40fbb36bb89cbd506cb", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "7c8775160af74e5fb8d25a30e8412c1a6476b0661529078a686e5118fd1c5e5f" + }, + "linux-x64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/linux/x64/agent-cli-package.tar.gz", + "archiveSha256": "8085fd120f5c71f4eae7fea26a043718e5644e3071e4fab3220a0e58c51f9593", + "closureSha256": "a375fc771dfe86b9b24532f95956ec4b02c5f4f24d80f5db06565160eb3b672c", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "bf04ef8ea6a63191067a6b3e15139aa2c793d8419daab246aa3f0a012e1bbcd9" + } + }, + "patchVersion": "paperclip-cursor-instructions-v2" + }, + "agentFilesBinding": "registered-runtime-context-v1", + "instructionBinding": "native-global-rules-v1", + "instructionAdmission": "synchronous-protocol-guard-v1", + "acpxPatchSha256": "79aad2d688b03362e8cfcbf7a08f78a8383869f6882a9c9ed66f1d18efb94f2b", + "sessionModeAdmission": "native-config-ack-recovery-bound-v1", + "sessionModes": [ + "agent", + "plan", + "ask" + ], + "defaultSessionMode": "agent", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativePlanToolIdentity": "request-item-id-bound-lifecycle-v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v8-identity.json b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v8-identity.json new file mode 100644 index 00000000000..2ac8431ace0 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v8-identity.json @@ -0,0 +1,55 @@ +{ + "commandDigest": "sha256:b9e94cbcdce2783665612c85caf019c4defe37d3d245a694de590ebdf5b1f1a3", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "cursor", + "agentProfileVersion": 8, + "acpxVersion": "0.13.1", + "agentServerVersion": "2026.09.26-dd393fe", + "patchVersion": "paperclip-cursor-usage-v3", + "distribution": { + "schema": "paperclip.cursor_distribution.v1", + "version": "2026.09.26-dd393fe", + "platforms": { + "darwin-arm64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/arm64/agent-cli-package.tar.gz", + "archiveSha256": "538827d96a779bab854a865c8e42859e8e87db34b5f69770261b90fc8cfff191", + "closureSha256": "0681abb675b46054155e0b3f28850bf4668cf24fb1aab457dad1f24b61b3ee6b", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25" + }, + "darwin-x64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/x64/agent-cli-package.tar.gz", + "archiveSha256": "ed1771c44cbf0f8059c67cac0d939e6a60eb730bd066d7dc4cbb14de314cfb6e", + "closureSha256": "18caa80f90f70f6a7d815335062b0858327891ec854aebe90b585a42eac578e6", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "7c8775160af74e5fb8d25a30e8412c1a6476b0661529078a686e5118fd1c5e5f" + }, + "linux-x64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/linux/x64/agent-cli-package.tar.gz", + "archiveSha256": "8085fd120f5c71f4eae7fea26a043718e5644e3071e4fab3220a0e58c51f9593", + "closureSha256": "0ee4ceb24062e991650bfb4c0b4ec5fbd7f1a01487b8c70aef0cf257509701a4", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "bf04ef8ea6a63191067a6b3e15139aa2c793d8419daab246aa3f0a012e1bbcd9" + } + }, + "patchVersion": "paperclip-cursor-usage-v3" + }, + "agentFilesBinding": "registered-runtime-context-v1", + "instructionBinding": "native-global-rules-v1", + "instructionAdmission": "synchronous-protocol-guard-v1", + "acpxPatchSha256": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e", + "sessionModeAdmission": "native-config-ack-recovery-bound-v1", + "sessionModes": [ + "agent", + "plan", + "ask" + ], + "defaultSessionMode": "agent", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativePlanToolIdentity": "request-item-id-bound-lifecycle-v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v9-identity.json b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v9-identity.json new file mode 100644 index 00000000000..1d968894f6d --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v9-identity.json @@ -0,0 +1,55 @@ +{ + "commandDigest": "sha256:a76ad26878a3b3328154901563cbda857e53583e4e01787f35a797992ef76162", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "cursor", + "agentProfileVersion": 9, + "acpxVersion": "0.13.1", + "agentServerVersion": "2026.09.26-dd393fe", + "patchVersion": "paperclip-cursor-usage-v4", + "distribution": { + "schema": "paperclip.cursor_distribution.v1", + "version": "2026.09.26-dd393fe", + "platforms": { + "darwin-arm64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/arm64/agent-cli-package.tar.gz", + "archiveSha256": "538827d96a779bab854a865c8e42859e8e87db34b5f69770261b90fc8cfff191", + "closureSha256": "257424bd48e35412091c6adfc61e4648e836757ec1d240d890bba81a24918c30", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25" + }, + "darwin-x64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/darwin/x64/agent-cli-package.tar.gz", + "archiveSha256": "ed1771c44cbf0f8059c67cac0d939e6a60eb730bd066d7dc4cbb14de314cfb6e", + "closureSha256": "6f28c799c5afdc64fbdff8a2157f565f17ae7615efe014ac389d63bf70cf2be2", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "7c8775160af74e5fb8d25a30e8412c1a6476b0661529078a686e5118fd1c5e5f" + }, + "linux-x64": { + "url": "https://downloads.cursor.com/lab/2026.09.26-dd393fe/linux/x64/agent-cli-package.tar.gz", + "archiveSha256": "8085fd120f5c71f4eae7fea26a043718e5644e3071e4fab3220a0e58c51f9593", + "closureSha256": "eadb8bb8ffb0450455b15b88c9b230307a9e149958a0c452d16dd157b4633d74", + "executable": "node", + "entrypoint": "index.js", + "vendorClosureSha256": "bf04ef8ea6a63191067a6b3e15139aa2c793d8419daab246aa3f0a012e1bbcd9" + } + }, + "patchVersion": "paperclip-cursor-usage-v4" + }, + "agentFilesBinding": "registered-runtime-context-v1", + "instructionBinding": "native-global-rules-v1", + "instructionAdmission": "synchronous-protocol-guard-v1", + "acpxPatchSha256": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e", + "sessionModeAdmission": "native-config-ack-recovery-bound-v1", + "sessionModes": [ + "agent", + "plan", + "ask" + ], + "defaultSessionMode": "agent", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativePlanToolIdentity": "request-item-id-bound-lifecycle-v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/provider-pack-darwin-arm64.json b/packages/paperclip-runner/test/fixtures/cursor-acp/provider-pack-darwin-arm64.json new file mode 100644 index 00000000000..01f733b2468 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/provider-pack-darwin-arm64.json @@ -0,0 +1,67 @@ +{ + "schema": "paperclip-runner/remote-provider-pack/v1", + "digest": "sha256:38bc6dd39c13b0f5478a1018e26deb61b0333ab0242f3d857e3c79d3a34b7682", + "payload": { + "pins": { + "nodeMinimum": "24.11.0", + "codex": "0.156.0", + "opencode": "1.18.32", + "acpx": "0.13.1", + "grok": "1.0.13", + "claudeAcp": "0.73.0", + "codexAcp": "1.6.2" + }, + "target": { + "platform": "darwin", + "architecture": "arm64" + }, + "runnerSourceRevision": "25fb1b5b317e52a8ad50208d7681a1ee34bd939c", + "distDigest": "sha256:b849c70826bcc0fb353b319b55e779ac9a31a2007fd358ad16f3502ea3bc9160", + "bridgeDigest": "sha256:4093de09695e15878d1614ff73bdabd95600af1e996055c3c981a7cee67486e2", + "acpxProfileDigests": { + "grok": "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55", + "claude": "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", + "codex": "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3" + }, + "candidateProviders": { + "cursor": { + "version": "2026.09.26-dd393fe", + "profileDigest": "sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899", + "closureDigest": "sha256:77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25", + "qualification": "pending", + "path": "provider-assets/cursor/darwin-arm64", + "sha256": "sha256:3b6de19114d4df62f648604025a0d71937dfa7c2b15b397cce5da2c5da268994" + } + }, + "artifacts": { + "grokLauncher": { + "path": "dist/providers/grok/launcher.cjs", + "sha256": "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55" + }, + "nodeCommand": { + "path": "node_modules/node/bin/node", + "sha256": "sha256:27db838bb204ef7c21df2931f5656e4c8fb32e6e947f363a402b49714d32b5b1" + }, + "productionLock": { + "path": "pnpm-lock.yaml", + "sha256": "sha256:9eea60187c6c808efb4d936a234a8d8000beac45253091abd4c5348132bd1408" + }, + "opencodeCommand": { + "path": "node_modules/.bin/opencode", + "sha256": "sha256:abed4de9c27d7add7180a13159d5e535490ee46993d650db150a91d53cbe544a" + }, + "opencodeExecutable": { + "path": "node_modules/opencode-ai/bin/opencode.exe", + "sha256": "sha256:a3c45d4e1d6620b436851f1ef6b25c71befcf06a382e279a1eb1c2196424395e" + }, + "opencodeProxy": { + "path": "dist/cli/opencode-app-server-proxy.cjs", + "sha256": "sha256:c98afd501fb93b56388c0c0f673f9c815c9847ede906eb11a6f693ec56439862" + }, + "acpxSidecar": { + "path": "dist/cli/acpx-runtime-sidecar.cjs", + "sha256": "sha256:743f78a3bf8b5ba9632cbd8cd00f83d7f22dc2a5cffacbd022066bdefc67d837" + } + } + } +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/provider-pack-offline-proof.json b/packages/paperclip-runner/test/fixtures/cursor-acp/provider-pack-offline-proof.json new file mode 100644 index 00000000000..875aa4376df --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/provider-pack-offline-proof.json @@ -0,0 +1,204 @@ +{ + "schema": "paperclip-cursor-offline-pack-proof/v1", + "capturedAt": "2026-09-28T20:18:46.105Z", + "runnerSourceRevision": "25fb1b5b317e52a8ad50208d7681a1ee34bd939c", + "packDigest": "sha256:38bc6dd39c13b0f5478a1018e26deb61b0333ab0242f3d857e3c79d3a34b7682", + "target": { + "platform": "darwin", + "architecture": "arm64" + }, + "candidate": { + "version": "2026.09.26-dd393fe", + "profileDigest": "sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899", + "closureDigest": "sha256:77394184a89b0e7384971181da19c3c82d83a399b04e7944b3f94fe3d7e62b25", + "qualification": "pending", + "path": "provider-assets/cursor/darwin-arm64", + "sha256": "sha256:3b6de19114d4df62f648604025a0d71937dfa7c2b15b397cce5da2c5da268994" + }, + "productionLock": "sha256:9eea60187c6c808efb4d936a234a8d8000beac45253091abd4c5348132bd1408", + "launch": { + "entrypoint": "verified bundled node + index.js", + "arguments": [ + "--disable-project-configs", + "--disable-auto-update", + "acp" + ], + "credentials": "none; private home/config/data/cache; memory credential store", + "verification": "generic profile installation registry, whole closure digest verification, retained immutable native snapshot, closed module bootstrap", + "modelAdmission": "offline-installation-probe is a factory identity sentinel only; no ACP model selection or prompt; model remains unverified" + }, + "requests": [ + { + "method": "initialize", + "params": { + "protocolVersion": 1, + "clientCapabilities": { + "fs": { + "readTextFile": false, + "writeTextFile": false + }, + "terminal": false, + "_meta": { + "subagents": true + } + }, + "clientInfo": { + "name": "paperclip-offline-pack-probe", + "version": "1" + } + } + }, + { + "method": "cursor/list_available_models", + "params": {} + }, + { + "method": "session/list", + "params": {} + }, + { + "method": "session/fork", + "params": { + "sessionId": "probe" + } + }, + { + "method": "session/resume", + "params": { + "sessionId": "probe", + "cwd": "/qualification/private/workspace", + "mcpServers": [] + } + }, + { + "method": "session/new", + "params": { + "cwd": "/qualification/private/workspace", + "mcpServers": [] + } + } + ], + "responses": [ + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "mcpCapabilities": { + "http": true, + "sse": true + }, + "promptCapabilities": { + "audio": false, + "embeddedContext": false, + "image": true + }, + "sessionCapabilities": { + "list": {}, + "subagents": {} + } + }, + "authMethods": [ + { + "id": "cursor_login", + "name": "Cursor Login", + "description": "Authenticate using existing Cursor login credentials. Run 'agent login' first if not logged in." + } + ] + } + }, + { + "jsonrpc": "2.0", + "id": 2, + "error": { + "code": -32000, + "message": "Authentication required", + "data": { + "message": "Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'." + } + } + }, + { + "jsonrpc": "2.0", + "id": 3, + "error": { + "code": -32000, + "message": "Authentication required", + "data": { + "message": "Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'." + } + } + }, + { + "jsonrpc": "2.0", + "id": 4, + "error": { + "code": -32601, + "message": "\"Method not found\": session/fork", + "data": { + "method": "session/fork" + } + } + }, + { + "jsonrpc": "2.0", + "id": 5, + "error": { + "code": -32601, + "message": "\"Method not found\": session/resume", + "data": { + "method": "session/resume" + } + } + }, + { + "jsonrpc": "2.0", + "id": 6, + "error": { + "code": -32000, + "message": "Authentication required", + "data": { + "message": "Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'." + } + } + } + ], + "process": { + "exitCode": 143, + "signalCode": null, + "leaseClosed": true, + "stderrBytes": 0, + "unexpectedMessageCount": 0 + }, + "qualification": { + "status": "pending", + "model": null, + "billablePrompts": 0, + "costUsd": 0, + "environment": "local macOS ARM64; no Daytona execution" + }, + "redactions": [ + "ephemeral workspace and private directories replaced with /qualification/private" + ], + "nativeEofProbe": { + "provider": "cursor", + "packSource": "25fb1b5b317e52a8ad50208d7681a1ee34bd939c", + "credentials": false, + "prompts": 0, + "startedAt": "2026-09-28T20:56:56.035Z", + "initialized": true, + "eofAt": "2026-09-28T20:56:57.776Z", + "eofSettled": false, + "exit": null, + "stderrBytes": 0, + "finishedAt": "2026-09-28T20:57:02.777Z", + "platform": "darwin", + "architecture": "arm64", + "gracefulExitDeadlineMs": 5000, + "fallback": "explicit owned process-group SIGTERM and native lease close", + "qualification": "EOF non-settlement observed; explicit cleanup is the supported runner lifecycle", + "evidenceSha256": "b99e972c14d1dbd731a5cef4d06e6ea54af6574fb4fe26fd44e78dfa9913fd7b" + } +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/runtime-patch-initialization.json b/packages/paperclip-runner/test/fixtures/cursor-acp/runtime-patch-initialization.json new file mode 100644 index 00000000000..ea009e2bb8c --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/runtime-patch-initialization.json @@ -0,0 +1,31 @@ +{ + "schema": "paperclip.cursor.patch-initialize-proof.v1", + "observedAt": "2026-09-29", + "patchVersion": "paperclip-cursor-isolation-v1", + "hostPlatform": "darwin-arm64", + "providerCalls": 0, + "authenticated": false, + "scope": "Direct patched vendor process initialization only; not a rebuilt Runner pack qualification. macOS x64 used host compatibility execution. Processes received SIGTERM after initialization.", + "results": [ + { + "platform": "darwin-arm64", + "providerCalls": 0, + "initialize": "passed", + "protocolVersion": 1, + "exitCode": 143, + "signal": null, + "error": "", + "closureSha256": "8a22016ca832ef8c8e6a83b955208d74c63f2c8dbe1dd688cbf27b81ad01886a" + }, + { + "platform": "darwin-x64", + "providerCalls": 0, + "initialize": "passed", + "protocolVersion": 1, + "exitCode": 143, + "signal": null, + "error": "", + "closureSha256": "2c461f48feb274f8e7aabd6da67d1e9aefbe6e0b232fc0a111dce874378afcc8" + } + ] +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/runtime-patch-offline-proof.json b/packages/paperclip-runner/test/fixtures/cursor-acp/runtime-patch-offline-proof.json new file mode 100644 index 00000000000..91aa95bca72 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/runtime-patch-offline-proof.json @@ -0,0 +1,80 @@ +{ + "schema": "paperclip.cursor.runtime-patch-proof.v1", + "patchVersion": "paperclip-cursor-instructions-v2", + "providerCalls": 0, + "qualification": "offline-only", + "platforms": [ + { + "instructions": { + "boundedPayloadRejected": true, + "explicitEmptyInstructions": true, + "nativeGlobalRule": true, + "nativeResourceAdditionalRules": true, + "newSession": { + "acknowledgementMatches": true, + "exactComposedBytesAtRequestContext": true, + "nativeRulePrecedencePreserved": true + }, + "loadSession": { + "acknowledgementMatches": true, + "exactComposedBytesAtRequestContext": true, + "nativeRulePrecedencePreserved": true + }, + "mainSourceSha256": "f8bd1c549f844859f8aeb9f06c01420f299bee22fe136695fe891eaf18674b12", + "boundary": "native RequestContext constructor; dependencies doubled; no inference" + }, + "platform": "darwin-arm64", + "file": "5672.index.js", + "sourceSha256": "7784c8b16d4e639814c13b12be2a687f5dc2cd98cbf29ed0a10820778ab1bf62", + "patchedSha256": "c01657c111f65153d7a40a923f01a5a86d393d1cd6893eaa03f71b9604d2af0c", + "driftRejected": true, + "fullChunkCompiles": true, + "ambientMcpAccesses": 0, + "ownedMcpPreserved": true, + "hookConfigReads": 0, + "remoteTeamHookFetches": 0, + "typedActionErrors": true, + "assistantProseIgnored": true + }, + { + "instructions": { + "boundedPayloadRejected": true, + "explicitEmptyInstructions": true, + "nativeGlobalRule": true, + "nativeResourceAdditionalRules": true + }, + "platform": "darwin-x64", + "file": "9841.index.js", + "sourceSha256": "3c0aaf6ecc4fceb0f95e1731deec75384984570d14d0871cecec11972b948ac4", + "patchedSha256": "cf0b5dbf4ec67219ce4fc49616af4c0041c4a9774e4b0840be170b749b8bc683", + "driftRejected": true, + "fullChunkCompiles": true, + "ambientMcpAccesses": 0, + "ownedMcpPreserved": true, + "hookConfigReads": 0, + "remoteTeamHookFetches": 0, + "typedActionErrors": true, + "assistantProseIgnored": true + }, + { + "instructions": { + "boundedPayloadRejected": true, + "explicitEmptyInstructions": true, + "nativeGlobalRule": true, + "nativeResourceAdditionalRules": true + }, + "platform": "linux-x64", + "file": "1699.index.js", + "sourceSha256": "2de420f1b31e70ca74b083a1ce5b519c2abffa5789d71cc06e84d2c38acb7c07", + "patchedSha256": "d3d634fc2080efac084f00e5d403a44d0edf1c39b02b02a4ed7d5ce0e8c703ff", + "driftRejected": true, + "fullChunkCompiles": true, + "ambientMcpAccesses": 0, + "ownedMcpPreserved": true, + "hookConfigReads": 0, + "remoteTeamHookFetches": 0, + "typedActionErrors": true, + "assistantProseIgnored": true + } + ] +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/usage-v3-runtime-patch-offline-proof.json b/packages/paperclip-runner/test/fixtures/cursor-acp/usage-v3-runtime-patch-offline-proof.json new file mode 100644 index 00000000000..d7d852d2155 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/usage-v3-runtime-patch-offline-proof.json @@ -0,0 +1,80 @@ +{ + "schema": "paperclip.cursor.runtime-patch-proof.v1", + "patchVersion": "paperclip-cursor-usage-v3", + "providerCalls": 0, + "qualification": "offline-only", + "platforms": [ + { + "instructions": { + "boundedPayloadRejected": true, + "explicitEmptyInstructions": true, + "nativeGlobalRule": true, + "nativeResourceAdditionalRules": true, + "newSession": { + "acknowledgementMatches": true, + "exactComposedBytesAtRequestContext": true, + "nativeRulePrecedencePreserved": true + }, + "loadSession": { + "acknowledgementMatches": true, + "exactComposedBytesAtRequestContext": true, + "nativeRulePrecedencePreserved": true + }, + "mainSourceSha256": "f8bd1c549f844859f8aeb9f06c01420f299bee22fe136695fe891eaf18674b12", + "boundary": "native RequestContext constructor; dependencies doubled; no inference" + }, + "platform": "darwin-arm64", + "file": "5672.index.js", + "sourceSha256": "7784c8b16d4e639814c13b12be2a687f5dc2cd98cbf29ed0a10820778ab1bf62", + "patchedSha256": "83fa4d462b3d8fd95b05420589cc80b8a6536dbff0cc3c5d36fec69d8a753970", + "driftRejected": true, + "fullChunkCompiles": true, + "ambientMcpAccesses": 0, + "ownedMcpPreserved": true, + "hookConfigReads": 0, + "remoteTeamHookFetches": 0, + "typedActionErrors": true, + "assistantProseIgnored": true + }, + { + "instructions": { + "boundedPayloadRejected": true, + "explicitEmptyInstructions": true, + "nativeGlobalRule": true, + "nativeResourceAdditionalRules": true + }, + "platform": "darwin-x64", + "file": "9841.index.js", + "sourceSha256": "3c0aaf6ecc4fceb0f95e1731deec75384984570d14d0871cecec11972b948ac4", + "patchedSha256": "060c8816fac3d110f5931ce626e5f2d841d8252fba7dafd52e37157ca915301d", + "driftRejected": true, + "fullChunkCompiles": true, + "ambientMcpAccesses": 0, + "ownedMcpPreserved": true, + "hookConfigReads": 0, + "remoteTeamHookFetches": 0, + "typedActionErrors": true, + "assistantProseIgnored": true + }, + { + "instructions": { + "boundedPayloadRejected": true, + "explicitEmptyInstructions": true, + "nativeGlobalRule": true, + "nativeResourceAdditionalRules": true + }, + "platform": "linux-x64", + "file": "1699.index.js", + "sourceSha256": "2de420f1b31e70ca74b083a1ce5b519c2abffa5789d71cc06e84d2c38acb7c07", + "patchedSha256": "3435f90f028eb5b08cf8cb7b289694993636f2868e6af217c167a5fd27ac03b2", + "driftRejected": true, + "fullChunkCompiles": true, + "ambientMcpAccesses": 0, + "ownedMcpPreserved": true, + "hookConfigReads": 0, + "remoteTeamHookFetches": 0, + "typedActionErrors": true, + "assistantProseIgnored": true + } + ] +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/usage-v4-runtime-patch-offline-proof.json b/packages/paperclip-runner/test/fixtures/cursor-acp/usage-v4-runtime-patch-offline-proof.json new file mode 100644 index 00000000000..a6f5180429a --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/usage-v4-runtime-patch-offline-proof.json @@ -0,0 +1,80 @@ +{ + "schema": "paperclip.cursor.runtime-patch-proof.v1", + "patchVersion": "paperclip-cursor-usage-v4", + "providerCalls": 0, + "qualification": "offline-only", + "platforms": [ + { + "instructions": { + "boundedPayloadRejected": true, + "explicitEmptyInstructions": true, + "nativeGlobalRule": true, + "nativeResourceAdditionalRules": true, + "newSession": { + "acknowledgementMatches": true, + "exactComposedBytesAtRequestContext": true, + "nativeRulePrecedencePreserved": true + }, + "loadSession": { + "acknowledgementMatches": true, + "exactComposedBytesAtRequestContext": true, + "nativeRulePrecedencePreserved": true + }, + "mainSourceSha256": "f8bd1c549f844859f8aeb9f06c01420f299bee22fe136695fe891eaf18674b12", + "boundary": "native RequestContext constructor; dependencies doubled; no inference" + }, + "platform": "darwin-arm64", + "file": "5672.index.js", + "sourceSha256": "7784c8b16d4e639814c13b12be2a687f5dc2cd98cbf29ed0a10820778ab1bf62", + "patchedSha256": "35c5bf13b261ea884bb0e7b26ae7bd4c4999b0b4b08ab338bc22f2ccce2b8108", + "driftRejected": true, + "fullChunkCompiles": true, + "ambientMcpAccesses": 0, + "ownedMcpPreserved": true, + "hookConfigReads": 0, + "remoteTeamHookFetches": 0, + "typedActionErrors": true, + "assistantProseIgnored": true + }, + { + "instructions": { + "boundedPayloadRejected": true, + "explicitEmptyInstructions": true, + "nativeGlobalRule": true, + "nativeResourceAdditionalRules": true + }, + "platform": "darwin-x64", + "file": "9841.index.js", + "sourceSha256": "3c0aaf6ecc4fceb0f95e1731deec75384984570d14d0871cecec11972b948ac4", + "patchedSha256": "5b2510248429febe1c9aead6ad43d35d89cdedc4dd057d4f07495efa33c7da55", + "driftRejected": true, + "fullChunkCompiles": true, + "ambientMcpAccesses": 0, + "ownedMcpPreserved": true, + "hookConfigReads": 0, + "remoteTeamHookFetches": 0, + "typedActionErrors": true, + "assistantProseIgnored": true + }, + { + "instructions": { + "boundedPayloadRejected": true, + "explicitEmptyInstructions": true, + "nativeGlobalRule": true, + "nativeResourceAdditionalRules": true + }, + "platform": "linux-x64", + "file": "1699.index.js", + "sourceSha256": "2de420f1b31e70ca74b083a1ce5b519c2abffa5789d71cc06e84d2c38acb7c07", + "patchedSha256": "38fe96fa71463372bffdd9efa3e5608293ff075d079149fe20a1625783c4eb39", + "driftRejected": true, + "fullChunkCompiles": true, + "ambientMcpAccesses": 0, + "ownedMcpPreserved": true, + "hookConfigReads": 0, + "remoteTeamHookFetches": 0, + "typedActionErrors": true, + "assistantProseIgnored": true + } + ] +} diff --git a/patches/acpx@0.13.1.patch b/patches/acpx@0.13.1.patch index 99c29c87fe6..5f117548528 100644 --- a/patches/acpx@0.13.1.patch +++ b/patches/acpx@0.13.1.patch @@ -1,8 +1,17 @@ diff --git a/dist/client-CxNllqui.d.ts b/dist/client-CxNllqui.d.ts -index 5e2113ac0a1a92c99322cf01e5c106760a0b0dbf..b7b5151f3da45b5e0d12aea55e9f3b071ab54a8d 100644 +index 5e2113ac0..45aff03c3 100644 --- a/dist/client-CxNllqui.d.ts +++ b/dist/client-CxNllqui.d.ts -@@ -135,6 +135,7 @@ declare class AcpClient { +@@ -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; @@ -11,10 +20,10 @@ index 5e2113ac0a1a92c99322cf01e5c106760a0b0dbf..b7b5151f3da45b5e0d12aea55e9f3b07 private setSessionModelThroughConfig; private setSessionModelThroughLegacyMethod; diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js -index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d2e777e9e 100644 +index d454fd7c5..6eba74345 100644 --- a/dist/live-checkpoint-BSIrfgVo.js +++ b/dist/live-checkpoint-BSIrfgVo.js -@@ -1068,6 +1068,7 @@ function serializeSessionRecordForDisk(record) { +@@ -1068,12 +1068,14 @@ function serializeSessionRecordForDisk(record) { last_agent_disconnect_reason: canonical.lastAgentDisconnectReason, protocol_version: canonical.protocolVersion, agent_capabilities: canonical.agentCapabilities, @@ -22,7 +31,14 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d title: canonical.title, messages: canonical.messages, updated_at: canonical.updated_at, -@@ -1239,7 +1240,8 @@ function parseRequestTokenUsage(raw) { + 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; @@ -32,7 +48,111 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d } return usage; } -@@ -1541,6 +1543,7 @@ function parseSessionRecord(raw) { +@@ -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), @@ -40,16 +160,25 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d title: conversation.title, messages: conversation.messages, updated_at: conversation.updated_at, -@@ -1661,7 +1664,7 @@ const ZED_TAG_KEYS = /* @__PURE__ */ new Set([ + 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", -@@ -3104,10 +3107,10 @@ function authEnvKey(methodId) { + "acpx.desired_config_options", +@@ -3104,10 +3198,10 @@ function authEnvKey(methodId) { authEnvKeyCache.set(methodId, key); return key; } @@ -62,7 +191,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d if (typeof value === "string" && value.trim().length > 0) return value; } function protectedEnvKey(key) { -@@ -3135,8 +3138,21 @@ function promotePrefixedAuthEnvironment(env) { +@@ -3135,8 +3229,21 @@ function promotePrefixedAuthEnvironment(env) { } return protectedKeys; } @@ -86,7 +215,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d const protectedAuthEnvKeys = promotePrefixedAuthEnvironment(env); if (authCredentials) for (const [methodId, credential] of Object.entries(authCredentials)) { addAuthCredentialEnvKeys(protectedAuthEnvKeys, methodId, credential); -@@ -3178,10 +3194,10 @@ function resolveConfiguredAuthCredential(methodId, authCredentials) { +@@ -3178,10 +3285,10 @@ function resolveConfiguredAuthCredential(methodId, authCredentials) { const configCredentials = authCredentials ?? {}; return configCredentials[methodId] ?? configCredentials[toEnvToken(methodId)]; } @@ -99,7 +228,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d stdio: [ "pipe", "pipe", -@@ -3961,10 +3977,13 @@ function resolveClientCapabilities(params) { +@@ -3961,10 +4068,13 @@ function resolveClientCapabilities(params) { ...params.elicitationModes.includes("url") ? { url: {} } : {} } } : {} }; @@ -115,7 +244,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d }; } function hasResponseField(response, field) { -@@ -4067,6 +4086,27 @@ function createNdJsonMessageStream(agentCommand, output, input) { +@@ -4067,6 +4177,27 @@ function createNdJsonMessageStream(agentCommand, output, input) { } }) }; } @@ -143,7 +272,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d var AcpClient = class { options; connection; -@@ -4105,7 +4145,9 @@ var AcpClient = class { +@@ -4105,7 +4236,9 @@ var AcpClient = class { cwd: asAbsoluteCwd(options.cwd), authPolicy: options.authPolicy ?? "skip", permissionPolicy: snapshotPermissionPolicy(options.permissionPolicy), @@ -154,7 +283,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d }; this.eventHandlers = { onAcpMessage: this.options.onAcpMessage, -@@ -4216,9 +4258,21 @@ var AcpClient = class { +@@ -4216,9 +4349,21 @@ var AcpClient = class { this.lastAgentExit = void 0; this.lastKnownPid = child.pid ?? void 0; this.attachAgentLifecycleObservers(child); @@ -176,7 +305,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d if (!this.options.verbose) return; process.stderr.write(chunk); }); -@@ -4226,6 +4280,7 @@ var AcpClient = class { +@@ -4226,6 +4371,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); @@ -184,7 +313,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d connection.signal.addEventListener("abort", () => { this.recordAgentExit("connection_close", child.exitCode ?? null, child.signalCode ?? null); }, { once: true }); -@@ -4253,7 +4308,12 @@ var AcpClient = class { +@@ -4253,7 +4399,12 @@ var AcpClient = class { geminiAcp: isGeminiAcpCommand(spawnCommand, args), copilotAcp: isCopilotAcpCommand(spawnCommand, args), claudeAcp: isClaudeAcpCommand(spawnCommand, args), @@ -198,7 +327,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d }; } logAgentLaunch(plan) { -@@ -4279,10 +4339,17 @@ var AcpClient = class { +@@ -4279,10 +4430,17 @@ var AcpClient = class { } async spawnAgentProcess(plan) { const spawnCommand = buildAgentSpawnCommand(plan.spawnCommand, plan.args, process.platform, plan.spawnOptions.env); @@ -218,7 +347,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d try { await waitForSpawn$1(spawnedChild); } catch (error) { -@@ -4293,10 +4360,10 @@ var AcpClient = class { +@@ -4293,10 +4451,10 @@ var AcpClient = class { createConnection(stream, launch) { const app = client({ name: "acpx" }).onNotification(methods.client.session.update, async ({ params }) => { await this.handleSessionUpdate(params); @@ -232,7 +361,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d }).onRequest(methods.client.fs.readTextFile, async ({ params }) => { return await this.handleReadTextFile(params); }).onRequest(methods.client.fs.writeTextFile, async ({ params }) => { -@@ -4312,6 +4379,11 @@ var AcpClient = class { +@@ -4312,6 +4470,11 @@ var AcpClient = class { }).onRequest(methods.client.terminal.release, async ({ params }) => { return await this.handleReleaseTerminal(params); }); @@ -244,7 +373,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d if (launch.devinAcp) app.onRequest("_cognition.ai/request_diagnostics", (params) => { return params && typeof params === "object" && !Array.isArray(params) ? params : {}; }, async () => ({})); -@@ -4333,12 +4405,12 @@ var AcpClient = class { +@@ -4333,12 +4496,12 @@ var AcpClient = class { async initializeProtocolConnection(connection, launch) { const initializePromise = connection.initialize({ protocolVersion: PROTOCOL_VERSION, @@ -259,10 +388,23 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d clientInfo: resolveClientInfo(launch.devinAcp) }); const initialized = launch.geminiAcp ? await withTimeout(initializePromise, resolveGeminiAcpStartupTimeoutMs()) : await initializePromise; -@@ -4358,6 +4430,36 @@ var AcpClient = class { +@@ -4358,6 +4521,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")); @@ -296,10 +438,13 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d const onAcpMessage = () => this.eventHandlers.onAcpMessage; const onAcpOutputMessage = () => this.eventHandlers.onAcpOutputMessage; const elicitationRequestIds = /* @__PURE__ */ new Set(); -@@ -4367,8 +4469,9 @@ var AcpClient = class { +@@ -4367,8 +4573,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); @@ -307,7 +452,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d if (requestId !== void 0) { elicitationRequestIds.add(requestId); return; -@@ -4379,6 +4482,7 @@ var AcpClient = class { +@@ -4379,6 +4589,7 @@ var AcpClient = class { } }; return { @@ -315,25 +460,37 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d readable: new ReadableStream({ async start(controller) { const reader = base.readable.getReader(); try { -@@ -4387,10 +4491,16 @@ var AcpClient = class { +@@ -4386,16 +4597,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(); +- controller.close(); } -@@ -4403,10 +4513,20 @@ var AcpClient = class { + } }), + writable: new WritableStream({ async write(message) { ++ enforceProtocol("outbound", message); + const promptOwner = promptRequestOwner(message); + if (promptOwner) bindPromptOwner(promptOwner); + const id = responseId(message); +@@ -4403,10 +4625,20 @@ var AcpClient = class { onAcpOutputMessage()?.("outbound", message); onAcpMessage()?.("outbound", message); } @@ -354,7 +511,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d writer.releaseLock(); } } }) -@@ -4422,7 +4542,10 @@ var AcpClient = class { +@@ -4422,7 +4654,10 @@ var AcpClient = class { const createPromise = this.runConnectionRequest(() => connection.newSession({ cwd: sessionCwd, mcpServers: this.options.mcpServers ?? [], @@ -366,7 +523,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d })); result = claudeAcp ? await withTimeout(createPromise, resolveClaudeAcpSessionCreateTimeoutMs()) : await createPromise; } catch (error) { -@@ -4864,7 +4987,7 @@ var AcpClient = class { +@@ -4864,7 +5099,7 @@ var AcpClient = class { } selectAuthMethod(methods) { for (const method of methods) { @@ -375,7 +532,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d if (envCredential) return { methodId: method.id, credential: envCredential, -@@ -4890,7 +5013,7 @@ var AcpClient = class { +@@ -4890,7 +5125,7 @@ var AcpClient = class { } readAgentSpecificEnvCredential(methodId) { if (!this.isGrokBuildAcpCommand() || methodId !== "xai.api_key") return; @@ -384,7 +541,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d return typeof value === "string" && value.trim().length > 0 ? value : void 0; } selectAgentManagedAuthMethod(methodId) { -@@ -4915,9 +5038,9 @@ var AcpClient = class { +@@ -4915,9 +5150,9 @@ var AcpClient = class { await connection.authenticate({ methodId: selected.methodId }); this.log(`authenticated with method ${selected.methodId} (${selected.source})`); } @@ -396,7 +553,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d if (hostResponse) return hostResponse; const { response, recorded } = await this.resolvePermissionRequestFromMode(params); if (!recorded) { -@@ -4926,14 +5049,49 @@ var AcpClient = class { +@@ -4926,14 +5161,81 @@ var AcpClient = class { } return response; } @@ -423,6 +580,38 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d + 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; @@ -448,7 +637,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d })).then((response) => ({ kind: "response", response -@@ -4970,7 +5128,7 @@ var AcpClient = class { +@@ -4970,7 +5272,7 @@ var AcpClient = class { isElicitationSessionCancelling(sessionId) { return this.closing || this.cancellingSessionIds.has(sessionId); } @@ -457,7 +646,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d if (!this.options.onPermissionRequest) return; const signal = this.cancellationSignalForSession(params.sessionId); try { -@@ -4978,7 +5136,7 @@ var AcpClient = class { +@@ -4978,7 +5280,7 @@ var AcpClient = class { sessionId: params.sessionId, raw: params, inferredKind: inferToolKind(params) @@ -466,7 +655,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d return this.hostPermissionDecisionResponse(params, signal, decision); } catch (error) { return this.hostPermissionErrorResponse(params, signal, error); -@@ -5028,6 +5186,12 @@ var AcpClient = class { +@@ -5028,6 +5330,12 @@ var AcpClient = class { attachAgentLifecycleObservers(child) { child.once("exit", (exitCode, signal) => { this.recordAgentExit("process_exit", exitCode, signal); @@ -479,7 +668,25 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d }); child.once("close", (exitCode, signal) => { this.recordAgentExit("process_close", exitCode, signal); -@@ -5771,10 +5935,20 @@ function recordSessionUpdate(conversation, state, notification, timestamp = isoN +@@ -5239,6 +5547,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 +6010,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 +6081,20 @@ function recordSessionUpdate(conversation, state, notification, timestamp = isoN trimConversationForRuntime(conversation); return acpx; } @@ -502,7 +709,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d updateConversationTimestamp(conversation, timestamp); trimConversationForRuntime(conversation); return true; -@@ -6435,6 +6609,27 @@ async function withConnectedSession(options) { +@@ -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; @@ -530,12 +737,13 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d async function runPromptTurn(params) { try { const promptPromise = params.client.prompt(params.sessionId, params.prompt, params.onPromptRequestStarted, params.onElicitation); -@@ -6444,7 +6639,22 @@ async function runPromptTurn(params) { +@@ -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 @@ -554,7 +762,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e3a4989473bba20e6dcdccbac086bb6d stopReason: response.stopReason, source: "rpc" diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts -index e8102acb03c4c38830ad5ec22f356125eb0423b7..d3a9ba6266a29926ed53302410534415d1fba4bf 100644 +index e8102acb0..377c5d838 100644 --- a/dist/runtime.d.ts +++ b/dist/runtime.d.ts @@ -1,7 +1,8 @@ @@ -598,7 +806,7 @@ index e8102acb03c4c38830ad5ec22f356125eb0423b7..d3a9ba6266a29926ed53302410534415 } | { type: "tool_call"; text: string; -@@ -312,7 +323,49 @@ type AcpRuntimeOptions = { +@@ -312,7 +323,51 @@ type AcpRuntimeOptions = { elicitationModes?: readonly AcpElicitationMode[]; onPermissionRequest?: (req: AcpPermissionRequest, ctx: { signal: AbortSignal; @@ -634,6 +842,8 @@ index e8102acb03c4c38830ad5ec22f356125eb0423b7..d3a9ba6266a29926ed53302410534415 + 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; @@ -648,7 +858,7 @@ index e8102acb03c4c38830ad5ec22f356125eb0423b7..d3a9ba6266a29926ed53302410534415 }; type AcpFileSessionStoreOptions = { stateDir: string; -@@ -369,6 +422,7 @@ declare class AcpRuntimeManager { +@@ -369,6 +424,7 @@ declare class AcpRuntimeManager { private createAndSaveRuntimeRecord; private retainInitializedSessionOwner; startTurn(input: { @@ -656,7 +866,7 @@ index e8102acb03c4c38830ad5ec22f356125eb0423b7..d3a9ba6266a29926ed53302410534415 handle: AcpRuntimeHandle; text: string; attachments?: AcpRuntimeTurnAttachment[]; -@@ -414,6 +468,7 @@ declare class AcpRuntimeManager { +@@ -414,6 +470,7 @@ declare class AcpRuntimeManager { private cleanupRuntimeTurn; private finalizeRuntimeTurnRecord; runTurn(input: { @@ -665,9 +875,27 @@ index e8102acb03c4c38830ad5ec22f356125eb0423b7..d3a9ba6266a29926ed53302410534415 text: string; attachments?: AcpRuntimeTurnAttachment[]; diff --git a/dist/runtime.js b/dist/runtime.js -index a1f4a70a003792c6eacf68b6b038f37bfec1db53..fe2484de6978fd16bfdd55ce69066fd43b1a0f77 100644 +index a1f4a70a0..a17fce3b3 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, @@ -371,7 +371,7 @@ const PROMPT_EVENT_PARSERS = { current_mode_update: (payload) => statusUpdateEvent("current_mode_update", payload), config_option_update: (payload) => statusUpdateEvent("config_option_update", payload), @@ -712,7 +940,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..fe2484de6978fd16bfdd55ce69066fd4 function normalizeUsageCost(value) { if (!isRecord(value)) return; const amount = asOptionalFiniteNumber(value.amount); -@@ -812,7 +840,21 @@ var AcpRuntimeManager = class { +@@ -812,7 +840,22 @@ var AcpRuntimeManager = class { this.deps = deps; } createClient(options) { @@ -727,6 +955,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..fe2484de6978fd16bfdd55ce69066fd4 + 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 @@ -735,7 +964,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..fe2484de6978fd16bfdd55ce69066fd4 } createSessionOwner(input) { const owner = { -@@ -821,12 +863,16 @@ var AcpRuntimeManager = class { +@@ -821,12 +864,16 @@ var AcpRuntimeManager = class { pendingSessionUpdates: [] }; input.client.setEventHandlers({ @@ -752,7 +981,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..fe2484de6978fd16bfdd55ce69066fd4 const active = owner.activeTurn; if (active) { const { task, turn } = active; -@@ -863,6 +909,9 @@ var AcpRuntimeManager = class { +@@ -863,6 +910,9 @@ var AcpRuntimeManager = class { projection.checkpoint.request(); } routeOwnedClientOperation(owner, operation) { @@ -762,7 +991,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..fe2484de6978fd16bfdd55ce69066fd4 const active = owner.activeTurn; if (!active) return; const { task, turn } = active; -@@ -1008,6 +1057,8 @@ var AcpRuntimeManager = class { +@@ -1008,6 +1058,8 @@ var AcpRuntimeManager = class { record.closedAt = void 0; record.protocolVersion = owner.client.initializeResult?.protocolVersion; record.agentCapabilities = owner.client.initializeResult?.agentCapabilities; @@ -771,7 +1000,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..fe2484de6978fd16bfdd55ce69066fd4 applyLifecycleSnapshotToRecord(record, owner.client.getAgentLifecycleSnapshot()); } async finishBufferedOwnerControl(owner, record) { -@@ -1086,6 +1137,11 @@ var AcpRuntimeManager = class { +@@ -1086,6 +1138,11 @@ var AcpRuntimeManager = class { record.closed = false; record.closedAt = void 0; this.closingActiveRecords.delete(record.acpxRecordId); @@ -783,7 +1012,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..fe2484de6978fd16bfdd55ce69066fd4 await this.options.sessionStore.save(record); return record; } -@@ -1149,6 +1205,8 @@ var AcpRuntimeManager = class { +@@ -1149,6 +1206,8 @@ var AcpRuntimeManager = class { this.closingActiveRecords.delete(record.acpxRecordId); record.protocolVersion = client.initializeResult?.protocolVersion; record.agentCapabilities = client.initializeResult?.agentCapabilities; @@ -792,7 +1021,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..fe2484de6978fd16bfdd55ce69066fd4 applyConfigOptionsToRecord(record, session.sessionResult); const modelApplication = await applyRequestedModelIfAdvertised({ client, -@@ -1301,6 +1359,7 @@ var AcpRuntimeManager = class { +@@ -1301,9 +1360,11 @@ var AcpRuntimeManager = class { client: turn.client, sessionId, prompt: task.promptInput, @@ -800,7 +1029,11 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..fe2484de6978fd16bfdd55ce69066fd4 timeoutMs: task.input.timeoutMs ?? this.options.timeoutMs, conversation: turn.conversation, promptMessageId: turn.promptMessageId, -@@ -1469,6 +1528,10 @@ var AcpRuntimeManager = class { ++ requestId: task.input.requestId, + onPromptRequestStarted: () => task.promptStarted.resolve(), + onElicitation: task.input.onElicitation + }); +@@ -1469,6 +1530,10 @@ var AcpRuntimeManager = class { setSessionConfigOption: async (configId, value) => { return (await task.state.activeController.setResolvedSessionConfigOption(configId, value)).response; }, @@ -811,7 +1044,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..fe2484de6978fd16bfdd55ce69066fd4 setResolvedSessionConfigOption: async (configId, value) => await this.setRuntimeResolvedSessionConfigOption(task, turn, configId, value) }; } -@@ -1575,6 +1638,8 @@ var AcpRuntimeManager = class { +@@ -1575,6 +1640,8 @@ var AcpRuntimeManager = class { reconcileAgentSessionId(turn.record, turn.record.agentSessionId); turn.record.protocolVersion = turn.client.initializeResult?.protocolVersion; turn.record.agentCapabilities = turn.client.initializeResult?.agentCapabilities; @@ -820,7 +1053,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..fe2484de6978fd16bfdd55ce69066fd4 turn.record.acpx = turn.acpxState; applyConversation(turn.record, turn.conversation); applyLifecycleSnapshotToRecord(turn.record, turn.client.getAgentLifecycleSnapshot()); -@@ -1707,6 +1772,17 @@ var AcpRuntimeManager = class { +@@ -1707,6 +1774,17 @@ var AcpRuntimeManager = class { }); await this.options.sessionStore.save(result.record); } @@ -838,7 +1071,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..fe2484de6978fd16bfdd55ce69066fd4 async cancel(handle) { await this.activeControllers.get(handle.acpxRecordId ?? handle.sessionKey)?.requestCancelActivePrompt(); } -@@ -2055,6 +2131,7 @@ var AcpxRuntime = class { +@@ -2055,6 +2133,7 @@ var AcpxRuntime = class { const turnPromise = this.getManager().then((manager) => manager.startTurn({ handle, text: input.text, @@ -846,7 +1079,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..fe2484de6978fd16bfdd55ce69066fd4 attachments: input.attachments, mode: input.mode, sessionMode: state.mode, -@@ -2087,6 +2164,7 @@ var AcpxRuntime = class { +@@ -2087,6 +2166,7 @@ var AcpxRuntime = class { yield* (await this.getManager()).runTurn({ handle, text: input.text, @@ -854,7 +1087,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..fe2484de6978fd16bfdd55ce69066fd4 attachments: input.attachments, mode: input.mode, sessionMode: state.mode, -@@ -2119,6 +2197,14 @@ var AcpxRuntime = class { +@@ -2119,6 +2199,14 @@ var AcpxRuntime = class { const { handle, state } = this.resolveManagerHandle(input.handle); await (await this.getManager()).setConfigOption(handle, input.key, input.value, state.mode); } @@ -870,7 +1103,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..fe2484de6978fd16bfdd55ce69066fd4 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..5ccc8dda101e7c90f61483c86fd2731855ce2e51 100644 +index c3da16452..40ae05653 100644 --- a/dist/session-options-DwRDODlr.d.ts +++ b/dist/session-options-DwRDODlr.d.ts @@ -1,4 +1,5 @@ @@ -888,7 +1121,7 @@ index c3da1645235bbea22de3f8484149051cd7dca56b..5ccc8dda101e7c90f61483c86fd27318 }; type AcpElicitationResponseMeta = { _meta?: Record | null; -@@ -116,6 +119,35 @@ type AcpClientOptions = { +@@ -116,6 +119,37 @@ type AcpClientOptions = { }; env?: Record; }; @@ -921,10 +1154,12 @@ index c3da1645235bbea22de3f8484149051cd7dca56b..5ccc8dda101e7c90f61483c86fd27318 + 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 +155,8 @@ type AcpClientOptions = { +@@ -123,6 +157,8 @@ type AcpClientOptions = { onPermissionEscalation?: (event: PermissionEscalationEvent) => void; onPermissionRequest?: (req: AcpPermissionRequest, ctx: { signal: AbortSignal; @@ -933,7 +1168,7 @@ index c3da1645235bbea22de3f8484149051cd7dca56b..5ccc8dda101e7c90f61483c86fd27318 }) => Promise; }; declare const SESSION_RECORD_SCHEMA: "acpx.session.v1"; -@@ -263,12 +297,15 @@ type SessionRecord = { +@@ -263,12 +299,16 @@ type SessionRecord = { lastAgentDisconnectReason?: string; protocolVersion?: number; agentCapabilities?: AgentCapabilities; @@ -947,6 +1182,7 @@ index c3da1645235bbea22de3f8484149051cd7dca56b..5ccc8dda101e7c90f61483c86fd27318 + request_token_usage: Record; ++ cursor_prompt_usage?: { request_id: string; prompt_message_id: string; receipt: Record }; acpx?: SessionAcpxState; importedFrom?: SessionImportedFrom; }; diff --git a/server/src/__tests__/native-cancellation-request.integration.test.ts b/server/src/__tests__/native-cancellation-request.integration.test.ts new file mode 100644 index 00000000000..16ae43c0221 --- /dev/null +++ b/server/src/__tests__/native-cancellation-request.integration.test.ts @@ -0,0 +1,163 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { agents, companies, createDb, heartbeatRuns, issues, nativeRunFinalizations } from "@paperclipai/db"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { rethrowNativeCancellationLockConflict, nativeRetryCancellationCommitCondition, cancellationIntentId, claimCancellationRequest, startupCancellationFence } from "../services/native-runtime/native-cancellation-request.js"; + +describe("atomic caller cancellation request ownership", () => { + let database: Awaited>; + let db: ReturnType; + let cancelNativeSession: typeof import("../services/native-runtime/native-session-executor.js").cancelNativeSession; + beforeAll(async () => { + ({ cancelNativeSession } = await import("../services/native-runtime/native-session-executor.js")); + database = await startEmbeddedPostgresTestDatabase("caller-stop-"); + db = createDb(database.connectionString); + }, 90_000); + afterAll(async () => { await database?.cleanup(); }); + async function fixture() { + const companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Stop claims", issuePrefix: randomUUID().slice(0, 8) }); + await db.insert(agents).values({ id: agentId, companyId, name: "Stop target" }); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", status: "running", runtimeMode: "native" }); + return { companyId, runId, agentId }; + } + const defaultFence = (runId: string) => db.update(heartbeatRuns).set({ resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || jsonb_build_object('startupCancellation', ${startupCancellationFence(new Date().toISOString())})` }).where(eq(heartbeatRuns.id, runId)); + it("allows exactly one of two concurrent identities and idempotent same-ID retries", async () => { + const { companyId, runId } = await fixture(), a = randomUUID(), b = randomUUID(); + const results = await Promise.allSettled([claimCancellationRequest(db, runId, companyId, a, "board-user"), claimCancellationRequest(db, runId, companyId, b, "board-user")]); + expect(results.filter(r => r.status === "fulfilled")).toHaveLength(1); + const winner = results[0].status === "fulfilled" ? a : b; + await expect(claimCancellationRequest(db, runId, companyId, winner, "other-board-user")).rejects.toMatchObject({ status: 409 }); + const copies = await Promise.all([claimCancellationRequest(db, runId, companyId, winner, "board-user"), claimCancellationRequest(db, runId, companyId, winner, "board-user")]); + expect(copies[0].resultJson).toEqual(copies[1].resultJson); + await expect(claimCancellationRequest(db, runId, randomUUID(), winner, "board-user")).rejects.toMatchObject({ status: 409 }); + }); + it("does not overwrite a caller claim when default Stop races after it", async () => { + const { companyId, runId } = await fixture(), id = randomUUID(); + const claimed = await claimCancellationRequest(db, runId, companyId, id, "board-user"); + await Promise.all([defaultFence(runId), claimCancellationRequest(db, runId, companyId, id, "board-user")]); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(run.resultJson).toEqual(claimed.resultJson); + }); + it("rejects a default Stop that won first, without replacing its marker", async () => { + const { companyId, runId } = await fixture(); await defaultFence(runId); + const [before] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + await expect(claimCancellationRequest(db, runId, companyId, randomUUID(), "board-user")).rejects.toMatchObject({ status: 409 }); + const [after] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(after.resultJson).toEqual(before.resultJson); + }); + it("accepts only the original acknowledged identity on a terminal run", async () => { + const { companyId, runId } = await fixture(), id = randomUUID(); + const run = await claimCancellationRequest(db, runId, companyId, id, "board-user"); + await db.update(heartbeatRuns).set({ status: "cancelled", resultJson: { ...run.resultJson, nativeCancellation: { schema: "paperclip.native-cancellation.v1", intentId: cancellationIntentId(id), runId, companyId, scope: "run", dispatched: true, dispatchState: "acknowledged", intentAuditId: randomUUID(), acknowledgementAuditId: randomUUID() } } }).where(eq(heartbeatRuns.id, runId)); + await expect(claimCancellationRequest(db, runId, companyId, id, "board-user")).resolves.toMatchObject({ status: "cancelled" }); + await expect(claimCancellationRequest(db, runId, companyId, randomUUID(), "board-user")).rejects.toMatchObject({ status: 409 }); + await db.update(heartbeatRuns).set({ resultJson: { ...run.resultJson, nativeCancellation: { intentId: cancellationIntentId(id) } } }).where(eq(heartbeatRuns.id, runId)); + await expect(claimCancellationRequest(db, runId, companyId, id, "board-user")).rejects.toMatchObject({ status: 409 }); + }); + async function retryFixture(phase = "retryable_failure") { + const f = await fixture(), issueId = randomUUID(); + await db.insert(issues).values({ id: issueId, companyId: f.companyId, title: "Native retry", status: "in_progress", assigneeAgentId: f.agentId }); + await db.update(heartbeatRuns).set({ nativeIssueId: issueId, status: "failed" }).where(eq(heartbeatRuns.id, f.runId)); + await db.insert(nativeRunFinalizations).values({ runId: f.runId, companyId: f.companyId, issueId, phase, nextAttemptAt: new Date(Date.now() + 30_000) }); + return { ...f, issueId }; + } + it("admits a failed retry and recovers the same caller after audited native cancellation disables it", async () => { + const f = await retryFixture(), id = randomUUID(); + await claimCancellationRequest(db, f.runId, f.companyId, id, "board-user"); + // No provider is attached: exercise the actual durable cancellation path. + const outcome = await cancelNativeSession(f.runId, "Stop", { db, scope: "run", cancellationRequestId: id }); + expect(outcome.auditId).toBeTruthy(); + const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)); + expect(coordinator).toMatchObject({ phase: "terminal_failure", failureCode: "native_retry_cancelled", nextAttemptAt: null }); + const retried = await claimCancellationRequest(db, f.runId, f.companyId, id, "board-user"); + expect(retried.resultJson?.nativeCancellation).toMatchObject({ intentId: cancellationIntentId(id), dispatchState: "acknowledged" }); + await expect(claimCancellationRequest(db, f.runId, f.companyId, randomUUID(), "board-user")).rejects.toMatchObject({ status: 409 }); + await expect(claimCancellationRequest(db, f.runId, f.companyId, id, "other-actor")).rejects.toMatchObject({ status: 409 }); + await expect(cancelNativeSession(f.runId, "Stop", { db, scope: "run", cancellationRequestId: id })).resolves.toMatchObject({ auditId: outcome.auditId }); + }); + it.each(["observed", "terminal_failure", "committed"])("does not claim a failed run whose coordinator advanced to %s", async phase => { + const f = await retryFixture(phase); + await expect(claimCancellationRequest(db, f.runId, f.companyId, randomUUID(), "board-user")).rejects.toMatchObject({ status: 409 }); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + expect(run.resultJson?.startupCancellation).toBeUndefined(); + }); + it("rejects a coordinator-only concurrent claim, then observes its committed phase without installing a Stop fence", async () => { + const f = await retryFixture(), id = randomUUID(); + let release!: () => void, locked!: () => void; + const acquired = new Promise(resolve => { locked = resolve; }); + const hold = new Promise(resolve => { release = resolve; }); + const advance = db.transaction(async tx => { + await tx.update(nativeRunFinalizations).set({ phase: "observed", nextAttemptAt: null }).where(eq(nativeRunFinalizations.runId, f.runId)); + locked(); await hold; + }); + try { + await acquired; + await expect(claimCancellationRequest(db, f.runId, f.companyId, id, "board-user")).rejects.toMatchObject({ status: 409, message: "Native retry cancellation is busy; retry the same request" }); + } finally { release(); await advance; } + await expect(claimCancellationRequest(db, f.runId, f.companyId, id, "board-user")).rejects.toMatchObject({ status: 409 }); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + expect(run.resultJson?.startupCancellation).toBeUndefined(); + }); + + it.each(["failed", "running"])("preserves a %s terminal/recovery outcome that wins after reservation", async status => { + const f = await retryFixture(), id = randomUUID(); + await claimCancellationRequest(db, f.runId, f.companyId, id, "board-user"); + await db.update(nativeRunFinalizations).set({ phase: status === "failed" ? "terminal_failure" : "observed", + failureCode: "board_recovery", nextAttemptAt: null }).where(eq(nativeRunFinalizations.runId, f.runId)); + await db.update(heartbeatRuns).set({ status, errorCode: "board_recovery" }).where(eq(heartbeatRuns.id, f.runId)); + await expect(cancelNativeSession(f.runId, "Stop", { db, scope: "run", cancellationRequestId: id })).rejects.toMatchObject({ status: 409 }); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + expect(run).toMatchObject({ status, errorCode: "board_recovery" }); + expect(run.resultJson?.nativeCancellation).toBeUndefined(); + }); + it("does not overwrite a coordinator-only terminal change after acknowledgement", async () => { + const f = await retryFixture(), id = randomUUID(); + await claimCancellationRequest(db, f.runId, f.companyId, id, "board-user"); + await cancelNativeSession(f.runId, "Stop", { db, scope: "run", cancellationRequestId: id }); + const [acknowledged] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + const commit = () => db.update(heartbeatRuns).set({ status: "cancelled" }).where(and(eq(heartbeatRuns.id, f.runId), + inArray(heartbeatRuns.status, ["queued", "running", "scheduled_retry", "failed"]), nativeRetryCancellationCommitCondition(acknowledged.resultJson))).returning(); + let release!: () => void, locked!: () => void; + const acquired = new Promise(resolve => { locked = resolve; }); + const hold = new Promise(resolve => { release = resolve; }); + const advance = db.transaction(async tx => { + await tx.update(nativeRunFinalizations).set({ failureCode: "board_recovery" }).where(eq(nativeRunFinalizations.runId, f.runId)); + locked(); await hold; + }); + try { await acquired; await expect(commit().catch(rethrowNativeCancellationLockConflict)).rejects.toMatchObject({ status: 409, message: "Native retry cancellation is busy; retry the same request" }); } + finally { release(); await advance; } + expect(await commit()).toEqual([]); + const [preserved] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + expect(preserved).toMatchObject({ status: "failed", resultJson: acknowledged.resultJson }); + }); + it.each(["running", "queued", "scheduled_retry"])("preserves a status-only advance to %s after acknowledgement", async status => { + const f = await retryFixture(), id = randomUUID(); + await claimCancellationRequest(db, f.runId, f.companyId, id, "board-user"); + await cancelNativeSession(f.runId, "Stop", { db, scope: "run", cancellationRequestId: id }); + const [acknowledged] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + await db.update(heartbeatRuns).set({ status }).where(eq(heartbeatRuns.id, f.runId)); + // Match heartbeat's broader candidate list: the production retry predicate, + // not a test-only failed-status filter, must preserve the newer state. + const committed = await db.update(heartbeatRuns).set({ status: "cancelled" }).where(and(eq(heartbeatRuns.id, f.runId), + inArray(heartbeatRuns.status, ["queued", "running", "scheduled_retry", "failed"]), + nativeRetryCancellationCommitCondition(acknowledged.resultJson))).returning(); + expect(committed).toEqual([]); + const [preserved] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + expect(preserved).toMatchObject({ status, resultJson: acknowledged.resultJson }); + }); + it("commits an unchanged acknowledged retry but preserves a newer run result", async () => { + const f = await retryFixture(), id = randomUUID(); + await claimCancellationRequest(db, f.runId, f.companyId, id, "board-user"); + await cancelNativeSession(f.runId, "Stop", { db, scope: "run", cancellationRequestId: id }); + const [acknowledged] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + const commit = () => db.update(heartbeatRuns).set({ status: "cancelled" }).where(and(eq(heartbeatRuns.id, f.runId), + inArray(heartbeatRuns.status, ["queued", "running", "scheduled_retry", "failed"]), nativeRetryCancellationCommitCondition(acknowledged.resultJson))).returning(); + await db.update(heartbeatRuns).set({ resultJson: { ...acknowledged.resultJson, boardRecovery: "new-outcome" } }).where(eq(heartbeatRuns.id, f.runId)); + expect(await commit()).toEqual([]); + await db.update(heartbeatRuns).set({ resultJson: acknowledged.resultJson }).where(eq(heartbeatRuns.id, f.runId)); + expect(await commit()).toMatchObject([{ status: "cancelled" }]); + }); + +}); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 11fdc1ab3c3..d68712a8d79 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,5 +1,6 @@ import { connectionIntentService } from "../services/connection-intents.js"; import { completeConnectionIntentSchema } from "@paperclipai/shared"; +import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js"; import { agentFileStore, agentFileTokenFromHash } from "../services/agent-file-store.js"; import { pipeline } from "node:stream/promises"; import { resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared"; @@ -7022,10 +7023,12 @@ export function agentRoutes( const runId = readHeartbeatRunId(req); const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!existing) return; + const requestId = cancellationRequestId(req.body?.cancellationRequestId); // Stamp the cancellation as operator-initiated (this route is board-only). // Recovery reads this to stand down instead of classifying the cancelled // run as agent stranding and re-waking the agent the operator just stopped. const run = await heartbeat.cancelRun(runId, "Cancelled by a board operator", { + ...(requestId ? { cancellationRequestId: requestId, cancellationRequestedByUserId: req.actor.userId ?? null } : {}), resultJson: { cancelledByActorType: "user", cancelledByUserId: req.actor.userId ?? null, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 4c7f3a281c6..0094e9001a4 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1,5 +1,6 @@ import { externalObjectService } from "./external-objects.js"; import { isAiAuthenticationBlocked } from "./ai-auth-failure.js"; +import { nativeRetryCancellationCommitCondition, rethrowNativeCancellationLockConflict, claimCancellationRequest, startupCancellationFence } from "./native-runtime/native-cancellation-request.js"; import { CHAT_COMPLETION_WAKE_REASON, prepareChatCompletionTurn, chatCompletionInstruction, isCompletedOnboardingHandoffWake } from "./chat-completion-delivery.js"; import { AgentDirectoryReuseInvalidatedError, isAgentDirectoryCopy } from "./agent-directory-working-copies.js"; @@ -9442,10 +9443,11 @@ export async function cancelHeartbeatNativeRun(input: { runId: string; reason: string; runtimeMode: string | null; + cancellationRequestId?: string; cancel?: ( runId: string, reason: string, - options: { db: Db; scope: "run" }, + options: { db: Db; scope: "run"; cancellationRequestId?: string }, ) => Promise<{ decision: unknown | null; auditId: string | null }>; }) { if (input.runtimeMode !== "native") { @@ -9455,10 +9457,12 @@ export async function cancelHeartbeatNativeRun(input: { ? await input.cancel(input.runId, input.reason, { db: input.db, scope: "run", + ...(input.cancellationRequestId ? { cancellationRequestId: input.cancellationRequestId } : {}), }) : await cancelNativeSession(input.runId, input.reason, { db: input.db, scope: "run", + ...(input.cancellationRequestId ? { cancellationRequestId: input.cancellationRequestId } : {}), }); if (!cancellation.decision || !cancellation.auditId) { throw new Error("native_cancellation_outcome_not_audited"); @@ -12988,6 +12992,7 @@ export function heartbeatService( fromStatuses: string[], patch?: Partial, failureReport?: RunFailureReportOptions, + cancellationCondition?: ReturnType, ) { // fromStatuses can name a terminal status as its own source (for example, // an idempotent "still failed" patch), so the write below is not always a @@ -13046,13 +13051,18 @@ export function heartbeatService( and( eq(heartbeatRuns.id, runId), inArray(heartbeatRuns.status, fromStatuses), + ...(cancellationCondition ? [cancellationCondition] : []), ...(isHeartbeatRunTerminalStatus(status) ? [nativeRunnerOwnershipNotHeldCondition()] : []), ), ) .returning() - .then((rows) => rows[0] ?? null); + .then((rows) => rows[0] ?? null) + .catch((error: unknown) => { + if (cancellationCondition) rethrowNativeCancellationLockConflict(error); + throw error; + }); if (updated) { publishLiveEvent({ @@ -23552,7 +23562,8 @@ export function heartbeatService( const savedFileInput = parseObject(parseObject(run.runnerProfileJson).nativeExecutionInput); const priorFileInput = Object.keys(savedFileInput).length ? savedFileInput : parseObject(parseObject(priorFileRun?.profile).nativeExecutionInput); const priorWorkingCopy = parseObject(parseObject(parseObject(priorFileInput.runtimeContext).instructions).workingCopy); - const warmFiles = nativeRuntimeResolution.kind === "native" && nativeRuntimeResolution.profile.backend === "codex_app_server" && + const warmFiles = nativeRuntimeResolution.kind === "native" && (nativeRuntimeResolution.profile.backend === "codex_app_server" || + (nativeRuntimeResolution.profile.backend === "acpx_runtime" && parseObject(agent.adapterConfig).acpxAgent === "cursor")) && (executionTarget?.kind === "remote" && executionTarget.transport === "sandbox" ? executionTarget.runnerLifecyclePolicy?.mode === "warm" : parseObject(agent.adapterConfig).lifecycleMode === "warm"); @@ -25097,6 +25108,11 @@ export function heartbeatService( await completeWorkspace(); } } catch (adapterErr) { + if (adapterErr instanceof NativeCancellationPendingRecoveryError) { + // Durable cancellation is settled by the outer recovery handler; + // it does not imply a failed workspace or a persisted run result. + throw adapterErr; + } if (adapterErr instanceof NativeWorkspaceFinalizationBusyError || adapterErr instanceof NativeWorkspaceFinalizationOwnershipLostError) { nativeWorkspaceFinalizeScheduled = true; @@ -29453,6 +29469,9 @@ export function heartbeatService( } type CancelRunOptions = { + /** Optional board request identity, atomically reserved for native Stop. */ + cancellationRequestId?: string; + cancellationRequestedByUserId?: string | null; errorCode?: string; resultJson?: Record; eventMessage?: string; @@ -29481,20 +29500,27 @@ export function heartbeatService( ) { let run = await getRun(runId); if (!run) throw notFound("Heartbeat run not found"); + if (options.cancellationRequestId) { + run = await claimCancellationRequest(db, runId, run.companyId, options.cancellationRequestId, options.cancellationRequestedByUserId ?? null); + } + // The caller claim checked retry eligibility under both durable row locks. + // This is only a cancellation candidate: dispatch rechecks the coordinator + // under lock, and the final CAS requires its own unchanged acknowledged + // retry fence. Re-reading only retryable_failure here would strand replay. const pendingNativeRetry = - run.runtimeMode === "native" && run.status === "failed" - ? await db - .select({ runId: nativeRunFinalizations.runId }) - .from(nativeRunFinalizations) - .where( - and( - eq(nativeRunFinalizations.runId, run.id), - eq(nativeRunFinalizations.companyId, run.companyId), - eq(nativeRunFinalizations.phase, "retryable_failure"), - ), - ) - .then((rows) => rows.length > 0) - : false; + run.runtimeMode === "native" && run.status === "failed" && ( + Boolean(options.cancellationRequestId) || await db + .select({ runId: nativeRunFinalizations.runId }) + .from(nativeRunFinalizations) + .where( + and( + eq(nativeRunFinalizations.runId, run.id), + eq(nativeRunFinalizations.companyId, run.companyId), + eq(nativeRunFinalizations.phase, "retryable_failure"), + ), + ) + .then((rows) => rows.length > 0) + ); if ( !pendingNativeRetry && !CANCELLABLE_HEARTBEAT_RUN_STATUSES.includes( @@ -29527,7 +29553,7 @@ export function heartbeatService( // Established legacy processes must still be stopped if the database is // unavailable. Only native or not-yet-dispatched preparation needs this // additional durable fence before its existing cancellation path. - if (run.runtimeMode === "native" || (!run.runtimeModeResolvedAt && !running && !control)) { + if (!options.cancellationRequestId && (run.runtimeMode === "native" || (!run.runtimeModeResolvedAt && !running && !control))) { const [fenced] = await db.update(heartbeatRuns).set({ // Record handoff intent before native cancellation can finalize and release // the run. Only its audited stop acknowledgement suppresses recovery. @@ -29617,6 +29643,7 @@ export function heartbeatService( runId: run.id, reason, runtimeMode: run.runtimeMode, + ...(options.cancellationRequestId ? { cancellationRequestId: options.cancellationRequestId } : {}), }); if (running) { await terminateHeartbeatRunProcess({ @@ -29719,6 +29746,8 @@ export function heartbeatService( } : {}), }, + undefined, + pendingNativeRetry ? nativeRetryCancellationCommitCondition(persistedCancellationResult) : undefined, ); } catch (error) { if (processCancellationSettlement) { diff --git a/server/src/services/native-runtime/completion-contracts.ts b/server/src/services/native-runtime/completion-contracts.ts index 3ce22758fcf..f8a642769db 100644 --- a/server/src/services/native-runtime/completion-contracts.ts +++ b/server/src/services/native-runtime/completion-contracts.ts @@ -10,6 +10,18 @@ import { nativeSha256 } from "./canonical.js"; export const NATIVE_COMPLETION_CONTRACT_SCHEMA = "paperclip.completion-contract.v1"; export const NATIVE_COMPLETION_POLICY_VERSION = "phase6-v4"; +/** Canonical persisted receipt includes the policy that governs the contract. */ +export function nativeCompletionContractSha256(row: Pick): string { + return nativeSha256({ + schemaVersion: row.schemaVersion, + policyVersion: row.policyVersion, + risk: row.risk, + completionAuthority: row.completionAuthority, + contract: row.contractJson, + }); +} + type CompletionComment = { id?: string; body: string; @@ -162,11 +174,11 @@ export async function ensureNativeCompletionContract(input: { immediateRequests: input.immediateRequests, humanResponseId: input.humanResponseId, }); - const latestCandidateSha256 = nativeSha256({ + const latestCandidateSha256 = nativeCompletionContractSha256({ schemaVersion: NATIVE_COMPLETION_CONTRACT_SCHEMA, policyVersion: NATIVE_COMPLETION_POLICY_VERSION, ...policy, - contract: latestCandidate, + contractJson: latestCandidate as unknown as Record, }); if (latest?.canonicalSha256 === latestCandidateSha256) { return { row: latest, contract: latestCandidate, sources: buildNativeCompletionContractSources(input) }; @@ -179,11 +191,11 @@ export async function ensureNativeCompletionContract(input: { immediateRequests: input.immediateRequests, humanResponseId: input.humanResponseId, }); - const canonicalSha256 = nativeSha256({ + const canonicalSha256 = nativeCompletionContractSha256({ schemaVersion: NATIVE_COMPLETION_CONTRACT_SCHEMA, policyVersion: NATIVE_COMPLETION_POLICY_VERSION, ...policy, - contract, + contractJson: contract as unknown as Record, }); const [row] = await tx.insert(completionContracts).values({ companyId: input.companyId, diff --git a/server/src/services/native-runtime/native-agent-runtime-inheritance.ts b/server/src/services/native-runtime/native-agent-runtime-inheritance.ts index efdcb3b898f..bbe10f6d2de 100644 --- a/server/src/services/native-runtime/native-agent-runtime-inheritance.ts +++ b/server/src/services/native-runtime/native-agent-runtime-inheritance.ts @@ -12,6 +12,7 @@ export const INHERITABLE_NATIVE_RUNNER_CONFIG_KEYS = [ "codexPermissionMode", "opencodePermissionMode", "acpxPermissionMode", + "acpxSessionMode", "lifecycleMode", "modelReasoningEffort", "maxIterations", diff --git a/server/src/services/native-runtime/native-cancellation-request.test.ts b/server/src/services/native-runtime/native-cancellation-request.test.ts new file mode 100644 index 00000000000..0706ec5b57b --- /dev/null +++ b/server/src/services/native-runtime/native-cancellation-request.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import { assertCancellationRequest, cancellationIntentId, cancellationRequestId, claimCancellationRequest } from "./native-cancellation-request.js"; +import { heartbeatRuns, type Db } from "@paperclipai/db"; +const id = "11111111-1111-4111-8111-111111111111"; +describe("caller-bound native cancellation", () => { + it.each([null, "", " x", {}, [], 1, `${id} `, "not-a-uuid"])("rejects malformed request %j", value => { + expect(() => cancellationRequestId(value)).toThrow("Invalid cancellationRequestId"); + }); + it("preserves the absent option and normalizes a valid UUID", () => { + expect(cancellationRequestId(undefined)).toBeUndefined(); + expect(cancellationRequestId("AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA")).toBe("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + }); + it.each([{ startupCancellation: {} }, { startupCancellation: { requestedAt: "earlier" } }, + { startupCancellation: { cancellationRequestId: "other" } }, { nativeCancellation: { intentId: "other" } }])("rejects prior or racing intent %j", result => { + expect(() => assertCancellationRequest(result, id, true)).toThrow("earlier Stop"); + }); + it("requires a reservation at the native locked dispatch gate", () => { + expect(() => assertCancellationRequest({}, id)).toThrow("earlier Stop"); + expect(() => assertCancellationRequest({}, id, true)).not.toThrow(); + const startupCancellation = { cancellationRequestId: id }; + expect(() => assertCancellationRequest({ startupCancellation }, id)).not.toThrow(); + expect(() => assertCancellationRequest({ startupCancellation, nativeCancellation: { intentId: cancellationIntentId(id) } }, id)).not.toThrow(); + }); +}); + + +describe("failed native retry caller admission", () => { + const companyId = "company", runId = "run", issueId = "issue"; + const claimed = { cancellationRequestId: id, requestedBy: { type: "board", userId: "board-user" } }; + const native = { schema: "paperclip.native-cancellation.v1", intentId: cancellationIntentId(id), + companyId, runId, issueId, scope: "run", dispatchState: "pending", dispatched: false, intentAuditId: "intent-audit" }; + function fixture(phase: string | null, resultJson: Record = {}, status = "failed", failureCode: string | null = null) { + const run = { id: runId, companyId, nativeIssueId: issueId, runtimeMode: "native", status, resultJson }; + const locks: unknown[][] = []; + const update = vi.fn((values: { resultJson: Record }) => { run.resultJson = values.resultJson; + return { where: () => ({ returning: async () => [run] }) }; }); + const tx = { select: () => ({ from: (table: unknown) => ({ where: () => ({ + for: (...args: unknown[]) => { locks.push(args); return { limit: async () => table === heartbeatRuns ? [run] + : phase ? [{ phase, failureCode }] : [] }; }, + }) }) }), update: () => ({ set: update }) }; + const db = { transaction: async (fn: (value: typeof tx) => Promise) => fn(tx) } as unknown as Db; + return { db, run, locks, update }; + } + it("admits a pending retry under the coordinator lock and preserves the exact caller on retry", async () => { + const f = fixture("retryable_failure"); + await claimCancellationRequest(f.db, runId, companyId, id, "board-user"); + const first = structuredClone(f.run.resultJson); + await claimCancellationRequest(f.db, runId, companyId, id, "board-user"); + expect(f.run.resultJson).toEqual(first); + expect(f.update).toHaveBeenCalledTimes(1); + expect(f.locks).toContainEqual(["update", { noWait: true }]); + }); + it.each([null, "observed", "executing", "terminal_failure", "committed"])("rejects missing or advanced retry phase %s without writing", async phase => { + const f = fixture(phase); + await expect(claimCancellationRequest(f.db, runId, companyId, id, "board-user")).rejects.toMatchObject({ status: 409 }); + expect(f.update).not.toHaveBeenCalled(); + }); + it.each(["pending", "acknowledged"])("recovers its own %s intent after native retry cancellation", async dispatchState => { + const f = fixture("terminal_failure", { startupCancellation: claimed, + nativeCancellation: { ...native, dispatchState, ...(dispatchState === "acknowledged" ? { acknowledgementAuditId: "ack-audit" } : {}) } }, "failed", "native_retry_cancelled"); + await expect(claimCancellationRequest(f.db, runId, companyId, id, "board-user")).resolves.toMatchObject({ status: "failed" }); + expect(f.update).not.toHaveBeenCalled(); + await expect(claimCancellationRequest(f.db, runId, companyId, id, "another-actor")).rejects.toMatchObject({ status: 409 }); + }); + it.each([{}, { ...native, intentId: "foreign" }, { ...native, issueId: "foreign" }, + { ...native, intentAuditId: null }, { ...native, dispatchState: "acknowledged" }])("rejects unproven or foreign cancellation recovery %j", nativeCancellation => { + const f = fixture("terminal_failure", { startupCancellation: claimed, nativeCancellation }, "failed", "native_retry_cancelled"); + return expect(claimCancellationRequest(f.db, runId, companyId, id, "board-user")).rejects.toMatchObject({ status: 409 }); + }); + it.each(["succeeded", "cancelled", "timed_out"])("does not admit terminal %s merely because a retry row exists", status => { + const f = fixture("retryable_failure", {}, status); + return expect(claimCancellationRequest(f.db, runId, companyId, id, "board-user")).rejects.toMatchObject({ status: 409 }); + }); + it.each([{ code: "55P03" }, { cause: { code: "55P03" } }])("fails closed on coordinator contention without database details", async error => { + const db = { transaction: async () => { throw error; } } as unknown as Db; + await expect(claimCancellationRequest(db, runId, companyId, id, "board-user")).rejects.toMatchObject({ status: 409, message: "Native retry cancellation is busy; retry the same request" }); + }); +}); diff --git a/server/src/services/native-runtime/native-cancellation-request.ts b/server/src/services/native-runtime/native-cancellation-request.ts new file mode 100644 index 00000000000..4d588ad9b18 --- /dev/null +++ b/server/src/services/native-runtime/native-cancellation-request.ts @@ -0,0 +1,133 @@ +import { and, eq, sql } from "drizzle-orm"; +import { heartbeatRuns, nativeRunFinalizations, type Db } from "@paperclipai/db"; +import { badRequest, conflict } from "../../errors.js"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const record = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +export function cancellationRequestId(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string" || !UUID.test(value)) throw badRequest("Invalid cancellationRequestId"); + return value.toLowerCase(); +} +export const cancellationIntentId = (requestId: string) => `native-cancellation:${requestId}`; + +/** Run only while holding this company's run row lock. An earlier uncorrelated + * startup fence is also a conflicting Stop, even before native intent exists. */ +export function assertCancellationRequest(result: unknown, requestId: string, allowUnclaimed = false) { + const value = record(result), startup = record(value.startupCancellation), native = record(value.nativeCancellation); + if ((Object.hasOwn(value, "startupCancellation") && startup.cancellationRequestId !== requestId) + || (Object.hasOwn(value, "nativeCancellation") && native.intentId !== cancellationIntentId(requestId)) + || (!allowUnclaimed && startup.cancellationRequestId !== requestId)) { + throw conflict("Cancellation request conflicts with an earlier Stop"); + } +} + +/** Evaluate only while both the run and coordinator are locked. A UUID alone + * is not retry authority; a resumed Stop needs its exact durable native intent. */ +export function nativeRetryCancellationEligible(input: { + runId: string; companyId: string; issueId: string; resultJson: unknown; requestId?: string; scope?: string; +}, coordinator: { phase?: string; failureCode?: string | null } | null | undefined) { + if (coordinator?.phase === "retryable_failure") return true; + if (coordinator?.phase !== "terminal_failure" || coordinator.failureCode !== "native_retry_cancelled") return false; + const result = record(input.resultJson), native = record(result.nativeCancellation); + return (!input.requestId || (record(result.startupCancellation).cancellationRequestId === input.requestId + && native.intentId === cancellationIntentId(input.requestId))) + && native.schema === "paperclip.native-cancellation.v1" && typeof native.intentId === "string" && !!native.intentId + && native.runId === input.runId && native.companyId === input.companyId && native.issueId === input.issueId + && native.scope === (input.scope ?? "run") && ["pending", "acknowledged"].includes(String(native.dispatchState)) + && typeof native.dispatched === "boolean" && typeof native.intentAuditId === "string" && !!native.intentAuditId + && (native.dispatchState === "pending" || (typeof native.acknowledgementAuditId === "string" + && !!native.acknowledgementAuditId && native.acknowledgementAuditId !== native.intentAuditId)); +} + +/** Final failed -> cancelled CAS: a later board/recovery outcome must win. + * The row-locking subquery prevents a coordinator-only writer from changing + * the retry disposition between this check and the status update. */ +export function nativeRetryCancellationCommitCondition(expectedResult: unknown) { + const native = record(record(expectedResult).nativeCancellation); + if (native.dispatchState !== "acknowledged" || typeof native.runId !== "string" + || typeof native.companyId !== "string" || typeof native.issueId !== "string" + || !nativeRetryCancellationEligible({ runId: native.runId, companyId: native.companyId, + issueId: native.issueId, resultJson: expectedResult }, { phase: "terminal_failure", failureCode: "native_retry_cancelled" })) { + return sql`false`; + } + return sql`${heartbeatRuns.status} = 'failed' and ${heartbeatRuns.runtimeMode} = 'native' + and ${heartbeatRuns.id} = ${native.runId} and ${heartbeatRuns.companyId} = ${native.companyId} + and ${heartbeatRuns.nativeIssueId} = ${native.issueId} + and ${heartbeatRuns.resultJson} = ${JSON.stringify(expectedResult)}::jsonb + and exists (select 1 from ${nativeRunFinalizations} + where ${nativeRunFinalizations.runId} = ${heartbeatRuns.id} + and ${nativeRunFinalizations.companyId} = ${heartbeatRuns.companyId} + and ${nativeRunFinalizations.issueId} = ${heartbeatRuns.nativeIssueId} + and ${nativeRunFinalizations.phase} = 'terminal_failure' + and ${nativeRunFinalizations.failureCode} = 'native_retry_cancelled' + for update nowait)`; +} + +export function rethrowNativeCancellationLockConflict(error: unknown): never { + const value = record(error); + if (value.code === "55P03" || record(value.cause).code === "55P03") { + throw conflict("Native retry cancellation is busy; retry the same request"); + } + throw error; +} + +/** Reserve the caller's identity before handoff or dispatch. No schema change: + * the existing startup fence and native intent carry the correlation. */ +export async function claimCancellationRequest(db: Db, runId: string, companyId: string, requestId: string, userId: string | null) { + if (cancellationRequestId(requestId) !== requestId) throw badRequest("Invalid cancellationRequestId"); + if (userId !== null && (typeof userId !== "string" || !userId)) throw badRequest("Invalid cancellation request actor"); + return db.transaction(async tx => { + const run = await tx.select().from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId))) + .for("update").limit(1).then(rows => rows[0]); + if (!run || run.runtimeMode !== "native") throw conflict("Correlated Stop requires a native run"); + assertCancellationRequest(run.resultJson, requestId, true); + const result = record(run.resultJson); + let pendingRetry = false; + if (run.status === "failed" && run.nativeIssueId) { + // Eligibility and the caller fence must share a transaction. Coordinator + // writers need not lock the run. NOWAIT avoids a lock-order deadlock with + // execution's coordinator -> run claim; a busy claim can be retried. + const coordinator = await tx.select().from(nativeRunFinalizations) + .where(and(eq(nativeRunFinalizations.runId, runId), eq(nativeRunFinalizations.companyId, companyId), + eq(nativeRunFinalizations.issueId, run.nativeIssueId))) + .for("update", { noWait: true }).limit(1).then(rows => rows[0]); + pendingRetry = nativeRetryCancellationEligible({ runId, companyId, issueId: run.nativeIssueId, + resultJson: result, requestId }, coordinator); + } + if (record(result.startupCancellation).cancellationRequestId === requestId) { + const actor = record(record(result.startupCancellation).requestedBy); + if (actor.type !== "board" || actor.userId !== userId) throw conflict("Cancellation request belongs to another actor"); + if (!pendingRetry && !["queued", "running", "scheduled_retry"].includes(run.status)) { + const native = record(result.nativeCancellation); + if (run.status !== "cancelled" || native.schema !== "paperclip.native-cancellation.v1" + || native.intentId !== cancellationIntentId(requestId) || native.runId !== runId || native.companyId !== companyId + || native.scope !== "run" || native.dispatchState !== "acknowledged" || typeof native.dispatched !== "boolean" + || typeof native.intentAuditId !== "string" || !native.intentAuditId + || typeof native.acknowledgementAuditId !== "string" || !native.acknowledgementAuditId + || native.intentAuditId === native.acknowledgementAuditId) throw conflict("Correlated Stop run is already terminal without acknowledgement"); + } + return run; + } + if (!pendingRetry && !["queued", "running", "scheduled_retry"].includes(run.status)) throw conflict("Correlated Stop run is already terminal"); + const [claimed] = await tx.update(heartbeatRuns).set({ resultJson: { ...result, + startupCancellation: { requestedAt: new Date().toISOString(), beforeNativeSelection: false, cancellationRequestId: requestId, requestedBy: { type: "board", userId }, ...(run.status === "failed" ? { retryCancellation: true } : {}) }, + } }).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId))).returning(); + if (!claimed) throw conflict("Correlated Stop run changed"); + return claimed; + }).catch(rethrowNativeCancellationLockConflict); +} + +/** Used by the default Stop path too: never erase a correlated reservation. */ +export function startupCancellationFence(requestedAt: string) { + return sql`CASE + WHEN ${heartbeatRuns.resultJson}->'startupCancellation'->>'cancellationRequestId' IS NOT NULL + THEN ${heartbeatRuns.resultJson}->'startupCancellation' + ELSE jsonb_build_object('requestedAt', ${requestedAt}::text, + 'beforeNativeSelection', ${heartbeatRuns.runtimeMode} = 'legacy' + and ${heartbeatRuns.runtimeModeResolvedAt} is null + and ${heartbeatRuns.executionStage} = 'preparing' + and coalesce(${heartbeatRuns.runnerProfileJson}->'adapterDispatch'->>'adapterType' = 'paperclip_runner', false)) + END`; +} diff --git a/server/src/services/native-runtime/native-completion-feedback.test.ts b/server/src/services/native-runtime/native-completion-feedback.test.ts new file mode 100644 index 00000000000..59971449917 --- /dev/null +++ b/server/src/services/native-runtime/native-completion-feedback.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { heartbeatRuns, issues, issueThreadInteractions, issueApprovals, type Db } from "@paperclipai/db"; +import type { PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js"; +import { nativeCompletionFeedback } from "./native-completion-feedback.js"; + +const state = vi.hoisted(() => ({ blockers: 0 })); +vi.mock("./native-deliverable-feedback.js", () => ({ validateNativeDeliverableEvidence: vi.fn(async () => undefined) })); +vi.mock("./automatic-completion-reviews.js", () => ({ findAutomaticCompletionReviews: vi.fn(async () => []) })); +vi.mock("../agent-invokability.js", () => ({ evaluateAgentInvokabilityFromDb: vi.fn() })); +vi.mock("../issues.js", () => ({ issueService: () => ({ getDependencyReadiness: async () => ({ unresolvedBlockerCount: state.blockers }) }) })); +vi.mock("./native-review-participant.js", () => ({ readNativeReviewAssignmentContext: () => null, getNativeReviewAssignment: vi.fn() })); +vi.mock("../../vendor/paperclip-runner/index.js", () => ({ normalizePrpResultSignals: () => ({ verification: [], actionableAttentionRequests: [] }) })); + +const result = { reportedWorkDisposition: "done", summary: "EXACT_PRIVATE_MARKER", completionClaim: { objectiveSatisfied: true, criteria: [], remainingWork: [] } } as unknown as PrpStructuredRunResult; +function database(options: { interaction?: boolean; approval?: boolean; review?: boolean } = {}): Db { + // Pure query double: no client, PostgreSQL process, or network is created. + return { select: () => ({ from: (table: unknown) => { + const rows = table === heartbeatRuns ? [{ id: "run", companyId: "company", nativeIssueId: "issue", contextSnapshot: {} }] + : table === issues ? [{ id: "issue", companyId: "company", status: "in_progress", ...(options.review ? { executionState: { status: "pending" } } : {}) }] + : table === issueThreadInteractions && options.interaction ? [{ id: "request", kind: "request_confirmation", title: "Decision" }] + : table === issueApprovals && options.approval ? [{ id: "approval" }] : []; + const query = { where: () => query, limit: () => query, innerJoin: () => query, then: (resolve: (value: unknown[]) => unknown) => Promise.resolve(rows).then(resolve) }; + return query; + } }) } as unknown as Db; +} +beforeEach(() => { state.blockers = 0; }); +describe("native completion feedback format precedence", () => { + it("honors requested final format only after normal completion acceptance without echoing the summary", async () => { + const feedback = await nativeCompletionFeedback(database(), "run", result); + expect(feedback).toContain("Follow the user's explicitly requested final-response format, including an exact response when requested."); + expect(feedback).toContain("Task status will be committed after this turn and workspace finalization finish."); + expect(feedback).toContain("Do not claim an approval is needed unless one was requested."); + expect(feedback).not.toContain(result.summary); + }); + it.each(["interaction", "approval", "review", "dependency"] as const)("preserves %s governance feedback instead of granting format precedence", async mode => { + state.blockers = mode === "dependency" ? 1 : 0; + const feedback = await nativeCompletionFeedback(database({ interaction: mode === "interaction", approval: mode === "approval", review: mode === "review" }), "run", result); + expect(feedback).not.toContain("final-response format"); + expect(feedback).toMatch(/still waiting|still pending|unresolved dependencies/); + }); +}); diff --git a/server/src/services/native-runtime/native-completion-feedback.ts b/server/src/services/native-runtime/native-completion-feedback.ts index d7b884b0b9e..4c5f2b9ba5a 100644 --- a/server/src/services/native-runtime/native-completion-feedback.ts +++ b/server/src/services/native-runtime/native-completion-feedback.ts @@ -210,5 +210,5 @@ export async function nativeCompletionFeedback( throw new Error("The named reviewer is not available in this company. Choose an available reviewer or report the concrete blocker."); } } - return "Completion report accepted. Task status will be committed after this turn and workspace finalization finish. Describe the completed work and any explicitly requested reviewer action; do not claim an approval is needed unless one was requested."; + return "Completion report accepted. Task status will be committed after this turn and workspace finalization finish. Follow the user's explicitly requested final-response format, including an exact response when requested. Otherwise describe the completed work and any explicitly requested reviewer action. Do not claim an approval is needed unless one was requested."; } diff --git a/server/src/services/native-runtime/native-cursor-plan-wait.test.ts b/server/src/services/native-runtime/native-cursor-plan-wait.test.ts new file mode 100644 index 00000000000..9d71ed4f0d4 --- /dev/null +++ b/server/src/services/native-runtime/native-cursor-plan-wait.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from "vitest"; +import * as runner from "../../vendor/paperclip-runner/index.js"; +import { resolveQualifiedAcpxProfile, validatePrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js"; +import { buildQuestionResponseDeliveryEnvelope } from "../question-response-delivery.js"; +import { NATIVE_COMPLETION_CONTRACT_SCHEMA, NATIVE_COMPLETION_POLICY_VERSION } from "./completion-contracts.js"; +import { nativeSha256 } from "./canonical.js"; +import { nativeCursorPlanWaitFromFacts, type CursorPlanWaitFacts } from "./native-cursor-plan-wait.js"; + +function fixture(): CursorPlanWaitFacts { + const b = { companyId: "company", issueId: "issue", agentId: "agent", runId: "run" }; + const contract = { revision: "revision", criteria: [{ id: "criterion" }] }; + const contractMetadata = { schemaVersion: NATIVE_COMPLETION_CONTRACT_SCHEMA, policyVersion: NATIVE_COMPLETION_POLICY_VERSION, risk: "low", completionAuthority: "agent_claim_policy" }; + const contractSha = nativeSha256({ ...contractMetadata, contract }); + const planId = `plan-${"a".repeat(64)}`; + const input = { schema: "paperclip.question_set.v1", title: "Plan", description: "Exact revised plan text", questions: [{ id: planId, prompt: "Proceed?", required: true, answerMode: "single_select", options: [{ id: "accept", label: "Accept" }, { id: "reject", label: "Reject" }, { id: "cancel", label: "Cancel" }] }] }; + const i = { id: "interaction", ...b, sourceRunId: b.runId, createdByAgentId: b.agentId, resolvedByUserId: "board", resolvedByAgentId: null, resolvedAt: new Date(0), + kind: "ask_user_questions", status: "answered", continuationPolicy: "none", idempotencyKey: "paperclip-runner-question:run:request", + payload: { runtimeRequestId: "request", questionSet: input }, result: { version: 1, answers: [{ questionId: planId, optionIds: ["accept"] }] } }; + const envelope = buildQuestionResponseDeliveryEnvelope(i as never); + const d = { id: "delivery", ...b, interactionId: i.id, sourceRunId: b.runId, targetRunId: b.runId, targetTurnId: null, status: "delivered", deliveryMode: "steered", acknowledgedAt: new Date(1), payloadSha256: nativeSha256(envelope) }; + const event = (sourceSeq: number, eventType: string, payload: Record) => { + const e = { schema: "paperclip.prp.event.v1", runId: b.runId, normalizedSessionId: "session", turnId: "turn", sourceInstanceId: "instance", sourceEventId: `instance:${sourceSeq}`, sourceSeq, sourceKind: "runner", schemaVersion: 1, eventType, payload }; + return { ...b, seq: sourceSeq, eventType, payload: { prpEvent: e }, sourceInstanceId: e.sourceInstanceId, sourceEventId: e.sourceEventId, sourceSeq, sourcePayloadSha256: nativeSha256(e), protocolSchemaVersion: 1 }; + }; + return { + binding: b, + run: { id: b.runId, companyId: b.companyId, agentId: b.agentId, nativeIssueId: b.issueId, runtimeMode: "native", runnerInstanceId: "instance", status: "running", completionContractId: "contract", completionContractSha256: contractSha, runnerProfileJson: { nativeExecutionInput: { binding: b, provider: { kind: "acpx", agent: "cursor", cursorMode: "plan", model: "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", profile: resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]") }, session: { normalizedSessionId: "session" }, completionContract: { id: "contract", sha256: contractSha, contract } } } }, + contract: { id: "contract", ...contractMetadata, canonicalSha256: contractSha, contractJson: contract }, + events: [ + event(275, "runtime_request.created", { request: { schema: "paperclip.runtime_request.v2", status: "pending", type: "input", requestKind: "runtime", requestId: "request", turnId: "turn", itemId: "native-plan-tool", origin: { provider: "cursor", method: "cursor/create_plan", adapter: "acpx-runtime-sidecar" }, input } }), + event(294, "tool.execution.started", { schema: "paperclip.tool.execution.v1", executionId: "native-plan-tool", transport: "builtin", operation: "execute", status: "running", name: "arbitrary display name" }), + event(300, "runtime_request.resolved", { requestId: "request", turnId: "turn", action: "submit", response: envelope.response }), + event(303, "tool.execution.completed", { schema: "paperclip.tool.execution.v1", executionId: "native-plan-tool", transport: "builtin", operation: "execute", status: "completed" }), + event(304, "turn.completed", { status: "completed", error: null }), + ], + interactions: [{ interaction: i, delivery: d }], + } as unknown as CursorPlanWaitFacts; +} +function editEvent(f: CursorPlanWaitFacts, index: number, edit: (e: any) => void) { + const row = f.events.filter(row => !row.eventType.startsWith("tool.execution."))[index]!; + const e = (row.payload as any).prpEvent; + edit(e); row.sourcePayloadSha256 = nativeSha256(e); +} + +describe("accepted Cursor plan passive-wait authority", () => { + it("records the accepted revision and explicitly unfinished Plan-mode continuation", () => { + const value = nativeCursorPlanWaitFromFacts(fixture()); + expect(value?.source).toMatchObject({ requestId: "request", planRevision: `plan-${"a".repeat(64)}`, terminalEventId: "instance:304", toolExecutionId: "native-plan-tool" }); + expect(value?.result).toMatchObject({ reportedWorkDisposition: "yielded", completionClaim: { objectiveSatisfied: false, remainingWork: [{ blocksCompletion: true }] }, continuation: { kind: "response_wake" } }); + expect(value?.result.summary).toContain("next message"); + const validated = validatePrpStructuredRunResult(value!.result); + expect(validated.ok).toBe(true); + if (validated.ok) expect(validated.result).toEqual(value!.result); + }); + it.each(["unrelated same-title tool", "cross turn", "cross session", "duplicate start", "duplicate completion", "missing start", "missing completion", "failed completion", "changed request tool", "uncommitted tool row"])("rejects correlated lifecycle corruption: %s", kind => { + const f = fixture(); + const start = f.events.find(row => row.eventType === "tool.execution.started")!; + const end = f.events.find(row => row.eventType === "tool.execution.completed")!; + const event = (start.payload as any).prpEvent; + if (kind === "unrelated same-title tool") event.payload.executionId = "unrelated-tool"; + if (kind === "cross turn") event.turnId = "other"; + if (kind === "cross session") event.normalizedSessionId = "other"; + if (kind === "duplicate start") f.events.splice(2, 0, structuredClone(start)); + if (kind === "duplicate completion") f.events.splice(4, 0, structuredClone(end)); + if (kind === "missing start") f.events = f.events.filter(row => row !== start); + if (kind === "missing completion") f.events = f.events.filter(row => row !== end); + if (kind === "failed completion") { (end.payload as any).prpEvent.payload.status = "failed"; end.sourcePayloadSha256 = nativeSha256((end.payload as any).prpEvent); } + if (kind === "changed request tool") editEvent(f, 0, e => { e.payload.request.itemId = "unrelated-tool"; }); + start.sourcePayloadSha256 = kind === "uncommitted tool row" ? null : nativeSha256(event); + expect(nativeCursorPlanWaitFromFacts(f)).toBeNull(); + }); + it("rejects an additional same-title tool while the real plan tool remains correlated", () => { + const f = fixture(); + const extra = structuredClone(f.events.find(row => row.eventType === "tool.execution.started")!); + extra.seq = 295; extra.sourceSeq = 295; extra.sourceEventId = "instance:295"; + const e = (extra.payload as any).prpEvent; + Object.assign(e, { sourceSeq: 295, sourceEventId: extra.sourceEventId }); + Object.assign(e.payload, { executionId: "unrelated", name: "Create Plan" }); + extra.sourcePayloadSha256 = nativeSha256(e); f.events.splice(2, 0, extra); + expect(nativeCursorPlanWaitFromFacts(f)).toBeNull(); + }); + it("binds actual callback-before-tool-start order without trusting display names", () => { + const f = fixture(); const original = nativeCursorPlanWaitFromFacts(f)!; + expect(original.source.toolLifecycleSha256).toMatch(/^[a-f0-9]{64}$/); + const start = f.events.find(row => row.eventType === "tool.execution.started")!; + (start.payload as any).prpEvent.payload.name = "changed display text"; + start.sourcePayloadSha256 = nativeSha256((start.payload as any).prpEvent); + const changed = nativeCursorPlanWaitFromFacts(f)!; + expect(changed).not.toBeNull(); + expect(changed.source.authoritySha256).not.toBe(original.source.authoritySha256); + expect(changed.source.toolLifecycleSha256).not.toBe(original.source.toolLifecycleSha256); + }); + it("does not create a new wait from an earlier profile after the catalog advances", () => { + const facts = fixture(); + expect(nativeCursorPlanWaitFromFacts(facts)).not.toBeNull(); + const current = resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"); + // Model a future catalog revision without tying this test to today's version. + const next = { ...current, agentProfileVersion: (current.agentProfileVersion + 1) as typeof current.agentProfileVersion, commandDigest: `sha256:${"b".repeat(64)}` } satisfies typeof current; + expect(next.agentProfileVersion).toBeGreaterThan(current.agentProfileVersion); + const resolver = vi.spyOn(runner, "resolveQualifiedAcpxProfile").mockReturnValue(next); + try { expect(nativeCursorPlanWaitFromFacts(facts)).toBeNull(); } + finally { resolver.mockRestore(); } + }); + it("projects only declared scope keys from a wider typed caller binding", () => { + const f = fixture(); Object.assign(f.binding, { wakeupRequestId: "unrelated-caller-metadata" }); + const proof = nativeCursorPlanWaitFromFacts(f); + expect(proof).not.toBeNull(); + expect(proof!.source).not.toHaveProperty("wakeupRequestId"); + }); + it.each([ + ["foreign runner", (f: CursorPlanWaitFacts) => { f.run.runnerInstanceId = "other"; }], + ["changed admission binding", (f: CursorPlanWaitFacts) => { (f.run.runnerProfileJson as any).nativeExecutionInput.binding = { ...f.binding, runId: "other" }; }], + ["wrong company", (f: CursorPlanWaitFacts) => { f.run.companyId = "other"; }], + ["wrong task", (f: CursorPlanWaitFacts) => { f.run.nativeIssueId = "other"; }], + ["failed run", (f: CursorPlanWaitFacts) => { f.run.status = "failed"; }], + ["cancelled run", (f: CursorPlanWaitFacts) => { f.run.status = "cancelled"; }], + ["Agent mode", (f: CursorPlanWaitFacts) => { (f.run.runnerProfileJson as any).nativeExecutionInput.provider.cursorMode = "agent"; }], + ["stale profile", (f: CursorPlanWaitFacts) => { (f.run.runnerProfileJson as any).nativeExecutionInput.provider.profile = { ...resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"), commandDigest: "old" }; }], + ["changed contract policy", (f: CursorPlanWaitFacts) => { f.contract.policyVersion = "tampered-policy"; }], + ["changed completion authority", (f: CursorPlanWaitFacts) => { f.contract.completionAuthority = "server_arbiter"; }], + ["changed contract hash", (f: CursorPlanWaitFacts) => { f.contract.canonicalSha256 = "f".repeat(64); }], + ["changed contract", (f: CursorPlanWaitFacts) => { f.contract.contractJson.revision = "new"; }], + ["unknown origin", (f: CursorPlanWaitFacts) => editEvent(f, 0, e => { e.payload.request.origin.provider = "acpx"; })], + ["wrong method", (f: CursorPlanWaitFacts) => editEvent(f, 0, e => { e.payload.request.origin.method = "cursor/ask_question"; })], + ["untrusted adapter", (f: CursorPlanWaitFacts) => editEvent(f, 0, e => { e.payload.request.origin.adapter = "other"; })], + ["cross turn", (f: CursorPlanWaitFacts) => editEvent(f, 1, e => { e.turnId = "other"; })], + ["cross session", (f: CursorPlanWaitFacts) => editEvent(f, 1, e => { e.normalizedSessionId = "other"; })], + ["native error", (f: CursorPlanWaitFacts) => editEvent(f, 2, e => { e.payload.error = { message: "failure" }; })], + ["changed plan", (f: CursorPlanWaitFacts) => editEvent(f, 0, e => { e.payload.request.input.description = "other"; })], + ["rejection", (f: CursorPlanWaitFacts) => editEvent(f, 1, e => { e.payload.response.answers[`plan-${"a".repeat(64)}`].selectedOptionIds = ["reject"]; })], + ["cancellation", (f: CursorPlanWaitFacts) => editEvent(f, 1, e => { e.payload.response.answers[`plan-${"a".repeat(64)}`].selectedOptionIds = ["cancel"]; })], + ["missing delivery", (f: CursorPlanWaitFacts) => { f.interactions = []; }], + ["unacknowledged delivery", (f: CursorPlanWaitFacts) => { f.interactions[0]!.delivery.acknowledgedAt = null; }], + ["fallback wake", (f: CursorPlanWaitFacts) => { f.interactions[0]!.delivery.deliveryMode = "wake_fallback"; }], + ["wrong target", (f: CursorPlanWaitFacts) => { f.interactions[0]!.delivery.targetRunId = "other"; }], + ["changed answer digest", (f: CursorPlanWaitFacts) => { f.interactions[0]!.delivery.payloadSha256 = "other"; }], + ["missing resolution time", (f: CursorPlanWaitFacts) => { f.interactions[0]!.interaction.resolvedAt = null; }], + ["agent resolved", (f: CursorPlanWaitFacts) => { f.interactions[0]!.interaction.resolvedByAgentId = "agent"; }], + ["duplicate receipt", (f: CursorPlanWaitFacts) => { f.events.splice(1, 0, structuredClone(f.events[0]!)); }], + ["duplicate delivery", (f: CursorPlanWaitFacts) => { f.interactions.push(structuredClone(f.interactions[0]!)); }], + ["uncommitted event", (f: CursorPlanWaitFacts) => { f.events[1]!.sourcePayloadSha256 = null; }], + ["changed row identity", (f: CursorPlanWaitFacts) => { f.events[1]!.sourceEventId = "other"; }], + ["terminal before answer", (f: CursorPlanWaitFacts) => { f.events.reverse(); }], + ] as const)("rejects %s", (_name, mutate) => { + const f = fixture(); mutate(f); expect(nativeCursorPlanWaitFromFacts(f)).toBeNull(); + }); + it("does not reuse an older acceptance after a new request, later work, or conflicting terminal", () => { + for (const kind of ["runtime_request.created", "tool.execution.started", "turn.failed", "turn.cancelled", "turn.completed"]) { + const f = fixture(); const row = structuredClone(f.events[0]!); + row.seq = 2.5; row.sourceSeq = 3; row.sourceEventId = "instance:later"; row.eventType = kind; + const e = (row.payload as any).prpEvent; Object.assign(e, { sourceSeq: 3, sourceEventId: row.sourceEventId, eventType: kind }); + if (kind === "runtime_request.created") e.payload.request.requestId = "new-plan"; + row.sourcePayloadSha256 = nativeSha256(e); + editEvent(f, 2, e => { e.sourceSeq = 4; }); f.events[2]!.sourceSeq = 4; + f.events.splice(2, 0, row); + expect(nativeCursorPlanWaitFromFacts(f)).toBeNull(); + } + }); +}); diff --git a/server/src/services/native-runtime/native-cursor-plan-wait.ts b/server/src/services/native-runtime/native-cursor-plan-wait.ts new file mode 100644 index 00000000000..9825fff4109 --- /dev/null +++ b/server/src/services/native-runtime/native-cursor-plan-wait.ts @@ -0,0 +1,243 @@ +import { and, asc, eq, gt, inArray, isNull, ne, sql } from "drizzle-orm"; +import { + completionContracts, heartbeatRunEvents, heartbeatRuns, issues, issueComments, + nativeRunFinalizations, nativeRunResults, statusDecisions, + issueQuestionResponseDeliveries, issueThreadInteractions, type Db, +} from "@paperclipai/db"; +import type { AskUserQuestionsInteraction } from "@paperclipai/shared"; +import { + parsePaperclipQuestionResponse, parsePaperclipQuestionSet, resolveQualifiedAcpxProfile, + type PrpStructuredRunResult, +} from "../../vendor/paperclip-runner/index.js"; +import { buildQuestionResponseDeliveryEnvelope } from "../question-response-delivery.js"; +import { nativeCompletionContractSha256 } from "./completion-contracts.js"; +import { nativeSha256 } from "./canonical.js"; + +type Binding = { companyId: string; issueId: string; runId: string; agentId: string }; +const record = (v: unknown): Record => v && typeof v === "object" && !Array.isArray(v) ? v as Record : {}; +const same = (a: unknown, b: unknown) => nativeSha256(a) === nativeSha256(b); +const PREFIX = "cursor-plan-wait:"; +// Preserve the exact Cursor6 proof query: progress rows were never part of its +// 1,000-row budget. Completed tools were included and must stay included. +const LEGACY_EVENT_TYPES = ["runtime_request.created", "runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired", "turn.started", "turn.completed", "turn.failed", "turn.cancelled", "tool.execution.started", "tool.execution.completed"]; +const EVENT_TYPES = [...LEGACY_EVENT_TYPES, "tool.execution.progressed"]; +const SUMMARY = "Plan accepted. This task is waiting for your next message. This run used Plan mode; no implementation or task completion is claimed."; + +export interface NativeCursorPlanWaitSource extends Binding { + schema: "paperclip.native_cursor_plan_wait.v1"; + contractId: string; + contractSha256: string; + interactionId: string; + requestId: string; + planRevision: string; + turnId: string; + normalizedSessionId: string; + sourceInstanceId: string; + requestEventId: string; + resolvedEventId: string; + terminalEventId: string; + deliveryId: string; + authoritySha256: string; + toolExecutionId?: string; + toolLifecycleSha256?: string; +} +export interface CursorPlanWaitFacts { + binding: Binding; + run: Pick; + contract: Pick; + events: Array>; + interactions: Array<{ interaction: typeof issueThreadInteractions.$inferSelect; delivery: typeof issueQuestionResponseDeliveries.$inferSelect }>; +} + +function isHistoricalUnboundWait(source?: NativeCursorPlanWaitSource): boolean { + return source !== undefined && source.toolExecutionId === undefined && source.toolLifecycleSha256 === undefined; +} + +/** Only committed native request/answer/normal-terminal facts can create this passive wait. */ +export function nativeCursorPlanWaitFromFacts(facts: CursorPlanWaitFacts) { + return cursorPlanWaitFromFacts(facts); +} + +function cursorPlanWaitFromFacts(facts: CursorPlanWaitFacts, committedSource?: NativeCursorPlanWaitSource): { source: NativeCursorPlanWaitSource; result: PrpStructuredRunResult } | null { + try { + const { run, contract } = facts; + const b: Binding = { companyId: facts.binding.companyId, issueId: facts.binding.issueId, runId: facts.binding.runId, agentId: facts.binding.agentId }; + const admission = record(record(run.runnerProfileJson).nativeExecutionInput); + const provider = record(admission.provider), profile = record(provider.profile); + // First admission must match today's qualified runtime. Recovery may retain + // a previously committed wait only if the entire original proof is unchanged. + if (!committedSource) { + const expected = resolveQualifiedAcpxProfile("cursor", typeof provider.model === "string" ? provider.model : ""); + if (!["agent", "driverKind", "protocolVersion", "acpxVersion", "commandDigest", "agentProfileVersion", "agentServerPackage", "agentServerVersion", "agentRuntimePackage", "agentRuntimeVersion"].every(key => profile[key] === record(expected)[key])) return null; + } + if (run.id !== b.runId || run.companyId !== b.companyId || run.agentId !== b.agentId || run.nativeIssueId !== b.issueId || run.runtimeMode !== "native" || !["running", "succeeded"].includes(run.status) || + !Object.entries(b).every(([key, value]) => record(admission.binding)[key] === value) || provider.kind !== "acpx" || provider.agent !== "cursor" || provider.cursorMode !== "plan" || typeof provider.model !== "string" || !provider.model.trim() || + record(admission.completionContract).id !== contract.id || record(admission.completionContract).sha256 !== contract.canonicalSha256 || nativeCompletionContractSha256(contract) !== contract.canonicalSha256 || run.completionContractId !== contract.id || run.completionContractSha256 !== contract.canonicalSha256 || !same(contract.contractJson, record(admission.completionContract).contract)) return null; + const sessionId = record(admission.session).normalizedSessionId; + if (typeof sessionId !== "string" || !sessionId || facts.events.length === 0 || facts.events.length > 1000) return null; + const events: Array> = []; + const ids = new Set(), seqs = new Set(); + let lastRowSeq = -1; + const lastSourceSeq = new Map(); + for (const row of facts.events) { + const event = record(record(row.payload).prpEvent); + if (!event.schema) continue; // Non-PRP aggregate rows are not native evidence. + if (row.companyId !== b.companyId || row.runId !== b.runId || row.agentId !== b.agentId || row.seq <= lastRowSeq || + event.schema !== "paperclip.prp.event.v1" || event.schemaVersion !== 1 || event.sourceInstanceId !== run.runnerInstanceId || event.sourceKind !== "runner" || event.runId !== b.runId || event.normalizedSessionId !== sessionId || + row.eventType !== event.eventType || row.sourceEventId !== event.sourceEventId || row.sourceInstanceId !== event.sourceInstanceId || row.sourceSeq !== event.sourceSeq || row.protocolSchemaVersion !== event.schemaVersion || + typeof event.sourceInstanceId !== "string" || !event.sourceInstanceId || typeof event.sourceEventId !== "string" || !event.sourceEventId || !Number.isSafeInteger(event.sourceSeq) || event.sourceSeq < 1 || row.sourcePayloadSha256 !== nativeSha256(event) || + (lastSourceSeq.get(event.sourceInstanceId) ?? 0) >= event.sourceSeq || ids.has(event.sourceEventId) || seqs.has(`${event.sourceInstanceId}:${event.sourceSeq}`)) return null; + lastSourceSeq.set(event.sourceInstanceId, event.sourceSeq); + lastRowSeq = row.seq; ids.add(event.sourceEventId); seqs.add(`${event.sourceInstanceId}:${event.sourceSeq}`); events.push(event); + } + const terminal = events.at(-1); + if (!terminal || terminal.eventType !== "turn.completed" || record(terminal.payload).status !== "completed" || record(terminal.payload).error != null || typeof terminal.turnId !== "string") return null; + const turn = events.filter(e => e.turnId === terminal.turnId); + if (turn.some(e => e.sourceInstanceId !== terminal.sourceInstanceId) || turn.filter(e => ["turn.completed", "turn.failed", "turn.cancelled"].includes(e.eventType)).length !== 1) return null; + const requests = turn.filter(e => e.eventType === "runtime_request.created"); + const created = requests.at(-1), request = record(record(created?.payload).request), origin = record(request.origin); + if (!created || request.schema !== "paperclip.runtime_request.v2" || request.type !== "input" || request.requestKind !== "runtime" || request.status !== "pending" || request.turnId !== terminal.turnId || + origin.provider !== "cursor" || origin.method !== "cursor/create_plan" || !["acpx-runtime", "acpx-runtime-sidecar"].includes(origin.adapter) || typeof request.requestId !== "string") return null; + if (new Set(requests.map(e => record(record(e.payload).request).requestId)).size !== requests.length) return null; + // No unresolved earlier input or later work is disguised as an accepted plan boundary. + for (const start of requests) { + const id = record(record(start.payload).request).requestId; + const ends = turn.filter(e => ["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"].includes(e.eventType) && record(e.payload).requestId === id); + if (ends.length !== 1 || ends[0]!.sourceSeq <= start.sourceSeq || ends[0]!.eventType !== "runtime_request.resolved") return null; + } + const resolved = turn.find(e => e.eventType === "runtime_request.resolved" && record(e.payload).requestId === request.requestId)!; + const resolution = record(resolved.payload); + if (resolved.sourceSeq >= terminal.sourceSeq || resolution.action !== "submit" || resolution.turnId !== terminal.turnId) return null; + const historicalUnboundWait = isHistoricalUnboundWait(committedSource); + let toolBinding: { toolExecutionId: string; toolLifecycleSha256: string } | undefined; + if (historicalUnboundWait) { + if (profile.agentProfileVersion !== 6 || profile.commandDigest !== "sha256:377dcea64a727ce799cc112458d4b40ba4bc6574cd6c6f7233b6efd5917a6c4b") return null; + // Reconstruct only the previously committed contract; never create a new + // unbound wait or let a catalog upgrade authorize additional work. + if (turn.some(e => e.sourceSeq > created.sourceSeq && e.eventType === "tool.execution.started")) return null; + } else { + const id = request.itemId; + if (typeof id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(id)) return null; + const tools = turn.filter(e => e.eventType.startsWith("tool.execution.")); + const lifecycle = tools.filter(e => record(e.payload).executionId === id); + const starts = lifecycle.filter(e => e.eventType === "tool.execution.started"); + const ends = lifecycle.filter(e => e.eventType === "tool.execution.completed"); + if (starts.length !== 1 || ends.length !== 1 || starts[0]!.sourceSeq >= resolved.sourceSeq || ends[0]!.sourceSeq <= resolved.sourceSeq || ends[0]!.sourceSeq >= terminal.sourceSeq || + lifecycle[0] !== starts[0] || lifecycle.at(-1) !== ends[0] || + lifecycle.some(e => record(e.payload).schema !== "paperclip.tool.execution.v1" || record(e.payload).transport !== "builtin" || record(e.payload).operation !== "execute" || record(e.payload).status !== (e.eventType === "tool.execution.completed" ? "completed" : "running")) || + tools.some(e => e.sourceSeq > created.sourceSeq && record(e.payload).executionId !== id)) return null; + toolBinding = { toolExecutionId: id, toolLifecycleSha256: nativeSha256(lifecycle) }; + } + const questionSet = parsePaperclipQuestionSet(request.input); + const plan = questionSet.questions.filter(q => /^plan-[a-f0-9]{64}$/.test(q.id)); + if (plan.length !== 1 || plan[0]!.answerMode !== "single_select" || !plan[0]!.required || !same(plan[0]!.options?.map(o => o.id), ["accept", "reject", "cancel"])) return null; + const response = parsePaperclipQuestionResponse(questionSet, resolution.response); + if (!same(response.answers[plan[0]!.id]?.selectedOptionIds, ["accept"])) return null; + const matches = facts.interactions.filter(({ interaction: i }) => record(i.payload).runtimeRequestId === request.requestId); + if (matches.length !== 1) return null; + const { interaction: i, delivery: d } = matches[0]!; + if (i.companyId !== b.companyId || i.issueId !== b.issueId || i.sourceRunId !== b.runId || i.createdByAgentId !== b.agentId || i.kind !== "ask_user_questions" || i.status !== "answered" || i.continuationPolicy !== "none" || !i.resolvedByUserId || !i.resolvedAt || i.resolvedByAgentId || + i.idempotencyKey !== `paperclip-runner-question:${b.runId}:${request.requestId}` || !same(record(i.payload).questionSet, questionSet) || + d.companyId !== b.companyId || d.issueId !== b.issueId || d.interactionId !== i.id || d.sourceRunId !== b.runId || d.targetRunId !== b.runId || d.status !== "delivered" || d.deliveryMode !== "steered" || !d.acknowledgedAt || (d.targetTurnId !== null && d.targetTurnId !== terminal.turnId)) return null; + const envelope = buildQuestionResponseDeliveryEnvelope(i as unknown as AskUserQuestionsInteraction); + if (d.payloadSha256 !== nativeSha256(envelope) || !same(parsePaperclipQuestionResponse(questionSet, envelope.response), response)) return null; + const source: NativeCursorPlanWaitSource = { + ...b, schema: "paperclip.native_cursor_plan_wait.v1", contractId: contract.id, contractSha256: contract.canonicalSha256, interactionId: i.id, requestId: request.requestId, planRevision: plan[0]!.id, + turnId: terminal.turnId, normalizedSessionId: sessionId, sourceInstanceId: terminal.sourceInstanceId, + requestEventId: created.sourceEventId, resolvedEventId: resolved.sourceEventId, terminalEventId: terminal.sourceEventId, deliveryId: d.id, + ...(toolBinding ?? {}), + authoritySha256: nativeSha256({ admission, contract, created, resolved, terminal, interaction: i, delivery: d, resolvedAt: i.resolvedAt?.toISOString(), acknowledgedAt: d.acknowledgedAt.toISOString(), ...(toolBinding ? { toolBinding } : {}) }), + }; + if (committedSource && !same(source, committedSource)) return null; + const ref = `interaction:${i.id}`; + const result: PrpStructuredRunResult = { + schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded", summary: SUMMARY, + completionClaim: { contractRevision: String(contract.contractJson.revision), objectiveSatisfied: false, + criteria: (contract.contractJson.criteria as Array<{id: string}>).map(c => ({ criterionId: c.id, status: "unknown", evidenceRefs: [ref] })), + remainingWork: [{ description: "Continue or finalize the accepted plan only after explicit direction; remain in the selected mode.", blocksCompletion: true }] }, + evidence: [{ ref }, { ref: `run-event:${resolved.sourceEventId}` }], verification: [], attentionRequests: [], + artifacts: [{ kind: "issue_thread_interaction", ref }], + continuation: { kind: "response_wake", summary: SUMMARY, idempotencyKey: `${PREFIX}${created.sourceEventId}` }, + }; + return { source, result }; + } catch { return null; } +} + +export function isNativeCursorPlanWaitResult(value: unknown): boolean { + const result = record(value); + return result.reportedWorkDisposition === "yielded" && typeof record(result.continuation).idempotencyKey === "string" && record(result.continuation).idempotencyKey.startsWith(PREFIX); +} + +/** The committer calls this again under its issue lock; rows are share-locked then. */ +export async function readNativeCursorPlanWait(db: Db, binding: Binding, locked = false) { + return readCursorPlanWaitProof(db, binding, locked); +} + +// Historical profile validation is available only after the committed-receipt +// query below establishes its scoped authority. New wait callers cannot select it. +async function readCursorPlanWaitProof(db: Db, binding: Binding, locked: boolean, committedSource?: NativeCursorPlanWaitSource) { + const q = db.select({ run: heartbeatRuns, contract: completionContracts }).from(heartbeatRuns) + .innerJoin(completionContracts, and(eq(completionContracts.id, heartbeatRuns.completionContractId), eq(completionContracts.companyId, binding.companyId), eq(completionContracts.issueId, binding.issueId))) + .innerJoin(issues, and(eq(issues.id, binding.issueId), eq(issues.companyId, binding.companyId), eq(issues.assigneeAgentId, binding.agentId))) + .where(and(eq(heartbeatRuns.id, binding.runId), eq(heartbeatRuns.companyId, binding.companyId), eq(heartbeatRuns.agentId, binding.agentId), eq(heartbeatRuns.nativeIssueId, binding.issueId))).limit(1); + const [row] = await (locked ? q.for("share", { noWait: true }) : q); + if (!row || record(record(row.run.runnerProfileJson).nativeExecutionInput).provider?.agent !== "cursor" || record(record(row.run.runnerProfileJson).nativeExecutionInput).provider?.cursorMode !== "plan") return null; + const eventTypes = isHistoricalUnboundWait(committedSource) ? LEGACY_EVENT_TYPES : EVENT_TYPES; + const eqs = db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.companyId, binding.companyId), eq(heartbeatRunEvents.runId, binding.runId), inArray(heartbeatRunEvents.eventType, eventTypes))).orderBy(asc(heartbeatRunEvents.seq)).limit(1001); + const events = await (locked ? eqs.for("share", { noWait: true }) : eqs); + const iq = db.select({ interaction: issueThreadInteractions, delivery: issueQuestionResponseDeliveries }).from(issueThreadInteractions) + .innerJoin(issueQuestionResponseDeliveries, eq(issueQuestionResponseDeliveries.interactionId, issueThreadInteractions.id)) + .where(and(eq(issueThreadInteractions.companyId, binding.companyId), eq(issueThreadInteractions.issueId, binding.issueId), eq(issueThreadInteractions.sourceRunId, binding.runId))).limit(101); + const interactions = await (locked ? iq.for("share", { noWait: true }) : iq); + if (interactions.length > 100) return null; + return cursorPlanWaitFromFacts({ binding, ...row, events, interactions }, committedSource); +} + +/** An exact applied wait suppresses recovery, not a later independently admitted user wake. */ +export async function hasCommittedNativeCursorPlanWait(db: Db, binding: Binding): Promise { + const [receipt] = await db.select({ decision: statusDecisions, result: nativeRunResults }) + .from(nativeRunFinalizations) + .innerJoin(statusDecisions, and( + eq(statusDecisions.id, nativeRunFinalizations.decisionId), + eq(statusDecisions.assessmentId, nativeRunFinalizations.assessmentId), + eq(statusDecisions.companyId, binding.companyId), eq(statusDecisions.issueId, binding.issueId), + eq(statusDecisions.runId, binding.runId), eq(statusDecisions.applicationState, "applied"), + eq(statusDecisions.toStatus, "in_progress"), eq(statusDecisions.reasonCode, "native_plan_accepted_waiting_for_continuation"), + )) + .innerJoin(nativeRunResults, and( + eq(nativeRunResults.id, nativeRunFinalizations.resultId), eq(nativeRunResults.companyId, binding.companyId), + eq(nativeRunResults.issueId, binding.issueId), eq(nativeRunResults.runId, binding.runId), eq(nativeRunResults.schemaStatus, "accepted"), + )) + .innerJoin(issues, and( + eq(issues.id, binding.issueId), eq(issues.companyId, binding.companyId), eq(issues.assigneeAgentId, binding.agentId), + eq(issues.status, "in_progress"), eq(issues.lastStatusDecisionId, statusDecisions.id), isNull(issues.hiddenAt), + sql`coalesce(${issues.executionState}->>'status', '') <> 'pending'`, + )) + .where(and(eq(nativeRunFinalizations.companyId, binding.companyId), eq(nativeRunFinalizations.issueId, binding.issueId), + eq(nativeRunFinalizations.runId, binding.runId), eq(nativeRunFinalizations.phase, "committed"))).limit(1); + if (!receipt) return false; + const committedSource = record(receipt.decision.decisionJson).cursorPlanWait as NativeCursorPlanWaitSource | undefined; + if (!committedSource) return false; + const proof = await readCursorPlanWaitProof(db, binding, false, committedSource); + const envelope = record(receipt.result.resultJson), terminal = record(envelope.terminal); + const acceptedIdentity = record(record(receipt.decision.decisionJson).cursorPlanWaitResult); + if (!proof || receipt.result.completionContractId !== proof.source.contractId || receipt.result.turnId !== proof.source.turnId || acceptedIdentity.resultId !== receipt.result.id || acceptedIdentity.resultSha256 !== receipt.result.canonicalSha256 || !same(record(receipt.decision.decisionJson).cursorPlanWait, proof.source) || !same(envelope.result, proof.result) || + terminal.schema !== "paperclip.prp.terminal.v1" || terminal.runTerminalState !== "succeeded" || terminal.turnTerminalState !== "completed" || terminal.reportedWorkDisposition !== "yielded") return false; + const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, binding.runId), eq(heartbeatRuns.companyId, binding.companyId))).limit(1); + if (!run || run.status !== "succeeded") return false; + // Settings for future runs cannot authorize work on this accepted plan. + // The original admission is checked above; only task-specific continuation + // or a superseding decision/run can release this committed passive wait. + const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, proof.source.interactionId)).limit(1); + if (!interaction?.resolvedAt) return false; + const [newRequest, newerRun] = await Promise.all([ + db.select({ id: issueComments.id }).from(issueComments).where(and( + eq(issueComments.companyId, binding.companyId), eq(issueComments.issueId, binding.issueId), + eq(issueComments.authorType, "user"), gt(issueComments.createdAt, interaction.resolvedAt), + )).limit(1), + db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and( + eq(heartbeatRuns.companyId, binding.companyId), eq(heartbeatRuns.nativeIssueId, binding.issueId), ne(heartbeatRuns.id, binding.runId), gt(heartbeatRuns.createdAt, run.createdAt), + )).limit(1), + ]); + return newRequest.length === 0 && newerRun.length === 0; +} diff --git a/server/src/services/native-runtime/native-execution-input.ts b/server/src/services/native-runtime/native-execution-input.ts index 839b52ccc4b..430c8d3e86f 100644 --- a/server/src/services/native-runtime/native-execution-input.ts +++ b/server/src/services/native-runtime/native-execution-input.ts @@ -1,4 +1,5 @@ import type { PaperclipTurnContext } from "@paperclipai/adapter-utils/server-utils"; +import { resolvePaperclipRunnerCursorMode } from "@paperclipai/adapter-utils"; import { createHash } from "node:crypto"; import { buildNativeContinuationPrompt } from "./native-continuation.js"; import type { @@ -71,6 +72,7 @@ export function buildNativeExecutionInput(input: { codexReasoningEffort?: string; opencodePermissionMode?: NativeOpenCodePermissionMode; acpxPermissionMode?: NativeAcpxPermissionMode; + acpxSessionMode?: "agent" | "plan" | "ask"; model?: string | null; managedProfile?: Extract< NativeExecutionInputV5["provider"], @@ -102,6 +104,7 @@ export function buildNativeExecutionInput(input: { if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask") { throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, or ask"); } + const cursorMode = resolvePaperclipRunnerCursorMode(input.provider, input.acpxAgent, input.acpxSessionMode); const executionMode = input.executionMode ?? (input.issue.workMode === "planning" ? "plan" : "default"); const acpxProfile = input.provider === "acpx" @@ -263,6 +266,7 @@ export function buildNativeExecutionInput(input: { agent: acpxProfile!.agent, model: input.model, permissionMode: input.acpxPermissionMode ?? "approve-all", + ...(cursorMode === undefined ? {} : { cursorMode }), profile: { driverKind: acpxProfile!.driverKind, protocolVersion: acpxProfile!.protocolVersion, diff --git a/server/src/services/native-runtime/native-run-finalizer.ts b/server/src/services/native-runtime/native-run-finalizer.ts index a1cc58d229c..339b6067c65 100644 --- a/server/src/services/native-runtime/native-run-finalizer.ts +++ b/server/src/services/native-runtime/native-run-finalizer.ts @@ -1,3 +1,4 @@ +import { isNativeCursorPlanWaitResult, readNativeCursorPlanWait } from "./native-cursor-plan-wait.js"; import { settleSlackConversation } from "../slack-conversation-lifecycle.js"; import { dismissAutomaticCompletionReviews } from "./automatic-completion-reviews.js"; import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-review-participant.js"; @@ -1274,7 +1275,17 @@ export async function finalizeNativeRun(input: { companyId: run.companyId, issueId: authoritativeIssue.id, agentId: run.agentId, contextSnapshot: reviewContext, allowResolvedByRunId: run.id, }) : null; + const cursorPlanWait = isNativeCursorPlanWaitResult(result) + ? await readNativeCursorPlanWait(input.db, { companyId: run.companyId, issueId: authoritativeIssue.id, runId: run.id, agentId: run.agentId }) + : null; + // Loss of the authority behind this server-issued wait must never fall + // through to the generic response_wake auto-continuation branch. + if (isNativeCursorPlanWaitResult(result) && + (!cursorPlanWait || nativeSha256(cursorPlanWait.result) !== nativeSha256(result))) { + throw new Error("native_cursor_plan_wait_authority_lost"); + } const proposedDecision = resolveNativeFinalizerStatus({ + cursorPlanWaitAuthorized: cursorPlanWait !== null, ...(reviewContext ? { nativeReviewOutcome: nativeReview ? nativeReview.interaction.status === "pending" ? "pending" as const : "resolved" as const : "stale" as const } : {}), @@ -1351,6 +1362,9 @@ export async function finalizeNativeRun(input: { priorStatusVersion: Number(authoritativeIssue.statusVersion), priorDecisionId: authoritativeIssue.lastStatusDecisionId, decision, + requireCursorPlanWaitSource: + decision.reasonCode === "native_plan_accepted_waiting_for_continuation" + ? cursorPlanWait?.source : undefined, requireBoardResponseWaitSource: decision.reasonCode === "board_response_waiting" || repairBoardResponseWait ? boardResponseWait?.source diff --git a/server/src/services/native-runtime/native-runner-file-handoff.ts b/server/src/services/native-runtime/native-runner-file-handoff.ts index 12b90d2930a..66c3a6542e5 100644 --- a/server/src/services/native-runtime/native-runner-file-handoff.ts +++ b/server/src/services/native-runtime/native-runner-file-handoff.ts @@ -883,10 +883,10 @@ export async function stageNativeRunnerWakeAttachments(input: { if (workspaceRoot) { const processDirectoryName = await currentStagingProcessDirectoryName(); await withStagingRegistryLock(workspaceRoot, async () => { - const stagingDirectory = await ensurePrivateStagingDirectory( - workspaceRoot, - processDirectoryName, - ); + // Empty wakes still scrub abandoned bytes, but need no new staging paths. + const stagingDirectory = selections.length > 0 + ? await ensurePrivateStagingDirectory(workspaceRoot, processDirectoryName) + : null; const activePaths = activeStagingPathsByWorkspace.get(workspaceRoot) ?? new Set(); const reusablePaths = await scrubNativeRunnerStagingResidue( @@ -894,7 +894,7 @@ export async function stageNativeRunnerWakeAttachments(input: { activePaths, processDirectoryName, ); - if (selections.length > 0) { + if (stagingDirectory !== null) { stagingDestinations = reusablePaths.slice(0, selections.length); while (stagingDestinations.length < selections.length) { const candidate = path.join(stagingDirectory, randomUUID()); 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 4ea8aa1b99b..4fc22f8b4cf 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -1150,6 +1150,17 @@ describe("remote provider pack manifest", () => { expect(readRemoteProviderPackManifest(root).payload.pins.opencode).toBe( "1.18.32", ); + const cursorPath = "provider-assets/cursor/linux-x64"; + await mkdir(join(root, cursorPath), { recursive: true }); + await writeFile(join(root, cursorPath, "runtime"), "pinned Cursor runtime"); + Object.assign(payload, { providers: { cursor: { version: "2026.09.26-dd393fe", profileDigest: digest("cursor-profile"), + closureDigest: digest("cursor-closure"), qualification: "pending", path: cursorPath, + sha256: sha256DirectoryTree(join(root, cursorPath)) } } }); + await writeManifest(); + expect(readRemoteProviderPackManifest(root).payload.providers?.cursor?.version).toBe("2026.09.26-dd393fe"); + await writeFile(join(root, cursorPath, "runtime"), "substitute Cursor runtime"); + expect(() => readRemoteProviderPackManifest(root)).toThrow("asset tree digest mismatch"); + await writeFile(join(root, cursorPath, "runtime"), "pinned Cursor runtime"); const candidatePath = "provider-assets/pi/linux-x64"; await mkdir(join(root, candidatePath), { recursive: true }); await writeFile(join(root, candidatePath, "runtime"), "pinned runtime"); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 148a5ebab3d..ff93f73a990 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -1,3 +1,5 @@ +import { nativeRetryCancellationEligible, rethrowNativeCancellationLockConflict, assertCancellationRequest, cancellationIntentId as callerCancellationIntentId, cancellationRequestId } from "./native-cancellation-request.js"; +import { readNativeCursorPlanWait } from "./native-cursor-plan-wait.js"; import { resolveAcpxQualification } from "./acpx-qualification.js"; import { readLocalAiCredentialFile } from "../local-ai-credential-file.js"; import { prepareGrokRunnerCredentials } from "./grok-runner-credentials.js"; @@ -138,7 +140,7 @@ import { renderNativeRunnerStagedAttachmentPrompt, stageNativeRunnerWakeAttachments, } from "./native-runner-file-handoff.js"; -import { nativeToolContractFingerprintForTarget } from "./native-session-resume.js"; +import { nativeRuntimeContractForProvider, nativeToolContractFingerprintForTarget } from "./native-session-resume.js"; import { verifyRetainedMaintenanceNoLaunch } from "./native-maintenance-no-launch.js"; import { registerRunnerPrpAuthority } from "../../realtime/runner-prp-ws.js"; import { connectRunnerPrpIngress } from "../../realtime/runner-prp-outbound.js"; @@ -155,7 +157,7 @@ import { type NativeAuthoritativeIssueStatus, type NativeStatusDecision, } from "./status-arbiter.js"; -import { HttpError } from "../../errors.js"; +import { conflict, HttpError } from "../../errors.js"; import { redactSensitiveText } from "../../redaction.js"; import { resolvePaperclipRunnerBinary } from "./native-codex-runner.js"; import { @@ -5272,6 +5274,7 @@ function nativeSessionConfigDigest( // without newly required tools. nativeToolContractFingerprint: nativeToolContractFingerprintForTarget(executionTargetKind), + runtimeContract: nativeRuntimeContractForProvider(execution.provider), }), ) .digest("hex")}`; @@ -5491,6 +5494,7 @@ export function providerSessionIdentityFromDurableProviderState(input: { identity.requestedModel !== expectedModel || identity.effectiveModel !== expectedModel || identity.permissionMode !== input.execution.provider.permissionMode || + !acpxRecoveryCursorModeMatches(input.execution.provider, descriptor.cursorMode, identity.cursorMode) || !["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes( String(identity.permissionMode), ) || @@ -5575,11 +5579,30 @@ function providerSessionIdentityIsPresent(value: unknown): boolean { ); } +// Recovery consumes observed identities; it must never apply the fresh-config +// default to a missing persisted mode or allow another provider to carry it. +function acpxRecoveryCursorModeMatches( + provider: NativeExecutionInput["provider"], + ...observedModes: unknown[] +): boolean { + const expected = record(provider).cursorMode; + if (provider.kind === "acpx" && provider.agent === "cursor") { + return (expected === "agent" || expected === "plan" || expected === "ask") + && observedModes.every(mode => mode === expected); + } + return expected === undefined && observedModes.every(mode => mode === undefined); +} + export function providerSessionIdentityTransitionIsAllowed(input: { execution: NativeExecutionInput; previous: unknown; current: unknown; }): boolean { + if (input.execution.provider.kind === "acpx" && !acpxRecoveryCursorModeMatches( + input.execution.provider, + record(record(input.previous).providerSessionIdentity).cursorMode, + record(record(input.current).providerSessionIdentity).cursorMode, + )) return false; if (canonicalJson(input.previous) === canonicalJson(input.current)) { return true; } @@ -6568,6 +6591,7 @@ export function cancelNativeSession( db: Db; scope?: "turn" | "run" | "issue"; replacementAccepted?: boolean; + cancellationRequestId?: string; }, ): Promise<{ dispatched: boolean; @@ -6582,6 +6606,7 @@ export async function cancelNativeSession( db: Db; scope?: "turn" | "run" | "issue"; replacementAccepted?: boolean; + cancellationRequestId?: string; }, ): Promise< | boolean @@ -6708,8 +6733,10 @@ export async function cancelNativeSession( } if (isNativeRunnerOwnershipHeld(lockedRun)) throw new NativeRunnerOwnershipUnverifiedError(); - const coordinator = await tx - .select({ runId: nativeRunFinalizations.runId }) + const retryCancellation = lockedRun.status === "failed" + || record(record(lockedRun.resultJson).startupCancellation).retryCancellation === true; + const coordinatorQuery = tx + .select({ runId: nativeRunFinalizations.runId, phase: nativeRunFinalizations.phase, failureCode: nativeRunFinalizations.failureCode }) .from(nativeRunFinalizations) .where( and( @@ -6717,13 +6744,25 @@ export async function cancelNativeSession( eq(nativeRunFinalizations.companyId, cancellationContext.companyId), eq(nativeRunFinalizations.issueId, cancellationContext.issueId), ), - ) - .limit(1) - .then((rows) => rows[0] ?? null); + ); + // Recheck after reservation, at the same transaction that records intent + // and disables the retry. NOWAIT avoids coordinator -> run lock inversion. + const coordinator = await (retryCancellation + ? coordinatorQuery.for("update", { noWait: true }) : coordinatorQuery) + .limit(1).then((rows) => rows[0] ?? null); if (!coordinator) throw new Error("native_cancellation_coordinator_missing"); const resultJson = record(lockedRun.resultJson); + // A default Stop joins an already reserved caller intent; it cannot + // replace that intent while the originating request is still dispatching. + const requestedId = options.cancellationRequestId + ?? cancellationRequestId(record(resultJson.startupCancellation).cancellationRequestId); + if (requestedId) assertCancellationRequest(resultJson, requestedId); + if (retryCancellation && !nativeRetryCancellationEligible({ + runId, companyId: cancellationContext.companyId, issueId: cancellationContext.issueId, + resultJson, requestId: requestedId, scope: options.scope ?? "run", + }, coordinator)) throw conflict("Native retry is no longer cancellable"); const existing = record(resultJson.nativeCancellation); const existingIntentId = typeof existing.intentId === "string" && existing.intentId.length > 0 @@ -6764,7 +6803,9 @@ export async function cancelNativeSession( }; } - const intentId = `native-cancellation:${randomUUID()}`; + const intentId = requestedId + ? callerCancellationIntentId(requestedId) + : `native-cancellation:${randomUUID()}`; const activity = await persistActivity(tx as unknown as Db, { companyId: cancellationContext.companyId, actorType: "system", @@ -6851,7 +6892,7 @@ export async function cancelNativeSession( priorCoordinatorDecisionId: cancellationContext.coordinatorDecisionId, existing: false, }; - }); + }).catch(rethrowNativeCancellationLockConflict); if (intentPublication) publishActivity(intentPublication); cancellationIntentId = intent.intentId; auditId = intent.auditId; @@ -8382,6 +8423,10 @@ async function executePaperclipNativeSessionWithinScope( if (terminalEvent.eventType !== "turn.completed") return null; const governedWait = await resolvePendingGovernedWait(); if (governedWait) return governedWait; + if (input.execution.provider.kind === "acpx" && input.execution.provider.agent === "cursor" && input.execution.provider.cursorMode === "plan") { + const planWait = await readNativeCursorPlanWait(input.db, input.execution.binding); + if (planWait?.source.terminalEventId === terminalEvent.sourceEventId) return planWait.result; + } const [conversation] = await input.db .select({ agentId: issues.conversationAgentId }) .from(issues) @@ -9441,6 +9486,10 @@ type RemoteProviderPackManifest = { distDigest: string; bridgeDigest: string; acpxProfileDigests: typeof REMOTE_PROVIDER_PACK_PROFILE_DIGESTS; + providers?: Partial>; candidateProviders?: Partial 3) { throw new Error("runner_remote_provider_artifact_incompatible: invalid candidate inventory"); } for (const [provider, candidate] of Object.entries(candidates)) { const expectedPath = `provider-assets/${provider}/${payload.target.platform}-${payload.target.architecture}`; - if (!["cursor", "copilot", "pi"].includes(provider) || !candidate + if (!(inventory === "providers" ? ["cursor"] : ["cursor", "copilot", "pi"]).includes(provider) || !candidate || Object.keys(candidate).some(key => !["version", "profileDigest", "closureDigest", "qualification", "path", "sha256"].includes(key)) || candidate.qualification !== "pending" || candidate.path !== expectedPath || typeof candidate.version !== "string" || !candidate.version || candidate.version.length > 120 @@ -11039,7 +11088,7 @@ async function createRunnerdBackendWithinSessionClaim( "const tree=(treeRoot)=>{const digest=crypto.createHash('sha256');const visit=(directory,prefix='')=>{for(const entry of fs.readdirSync(directory,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name))){const relative=prefix?prefix+'/'+entry.name:entry.name;const absolute=path.join(directory,entry.name);if(entry.isDirectory()){digest.update('directory\\0'+relative+'\\n');visit(absolute,relative)}else if(entry.isFile()){digest.update('file\\0'+relative+'\\0'+'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(absolute)).digest('hex')+'\\n')}else if(entry.isSymbolicLink()){digest.update('symlink\\0'+relative+'\\0'+fs.readlinkSync(absolute)+'\\n')}else throw new Error('unsupported dist entry '+relative)}};visit(treeRoot);return 'sha256:'+digest.digest('hex')}", "for(const name of ['nodeCommand','productionLock','opencodeCommand','opencodeExecutable','opencodeProxy','acpxSidecar','grokLauncher']){const artifact=manifest.payload.artifacts[name];if(hash(artifact.path)!==artifact.sha256)throw new Error(name+' digest mismatch')}", "if(tree(path.join(root,'dist'))!==manifest.payload.distDigest)throw new Error('dist tree digest mismatch')", - "for(const candidate of Object.values(manifest.payload.candidateProviders||{})){if(tree(path.join(root,candidate.path))!==candidate.sha256)throw new Error('candidate asset tree digest mismatch')}", + "for(const candidate of Object.values({...manifest.payload.providers,...manifest.payload.candidateProviders})){if(tree(path.join(root,candidate.path))!==candidate.sha256)throw new Error('candidate asset tree digest mismatch')}", "const version=process.versions.node.split('.').map(Number)", "const minimum=manifest.payload.pins.nodeMinimum.split('.').map(Number)", "if(version[0]> = null; + let cursorPlanWaitResult: { resultId: string; resultSha256: string } | null = null; + if (reasonCode === "native_plan_accepted_waiting_for_continuation") { + const expected = input.requireCursorPlanWaitSource; + if (!expected || expected.companyId !== input.companyId || expected.issueId !== input.issueId || expected.runId !== input.runId || input.decision.effects.length !== 0 || input.decision.statusAction !== "in_progress" || input.decision.toStatus !== "in_progress") throw new NativeStatusRaceError(); + try { + cursorPlanWait = await readNativeCursorPlanWait(tx as unknown as Db, expected, true); + if (!cursorPlanWait || nativeSha256(cursorPlanWait.source) !== nativeSha256(expected)) throw new NativeStatusRaceError(); + // Recheck the committed semantic result too: an arbitrary caller cannot + // use a genuine plan receipt to authorize a different finalization. + const [accepted] = await tx.select().from(nativeRunResults).where(and( + eq(nativeRunResults.id, coordinator!.resultId!), eq(nativeRunResults.companyId, input.companyId), + eq(nativeRunResults.issueId, input.issueId), eq(nativeRunResults.runId, input.runId), eq(nativeRunResults.schemaStatus, "accepted"), + )).for("share", { noWait: true }); + const envelope = record(accepted?.resultJson), terminal = record(envelope.terminal); + if (!accepted || accepted.completionContractId !== cursorPlanWait.source.contractId || accepted.turnId !== cursorPlanWait.source.turnId || + nativeSha256(envelope.result) !== nativeSha256(cursorPlanWait.result) || + terminal.schema !== "paperclip.prp.terminal.v1" || terminal.runTerminalState !== "succeeded" || terminal.turnTerminalState !== "completed" || terminal.reportedWorkDisposition !== "yielded") throw new NativeStatusRaceError(); + // completeRun hashes its full private binding, not the stored resultJson. + // Preserve that accepted identity instead of inventing a new digest. + cursorPlanWaitResult = { resultId: accepted.id, resultSha256: accepted.canonicalSha256 }; + } catch (error) { + if (isExternalChatWaitAuthorizationContention(error)) throw new NativeStatusRaceError(); + throw error; + } + } let boardResponseWaitOrigin: NativeBoardResponseWaitOrigin | null = null; if ( passiveBoardResponseWait || @@ -1759,6 +1787,7 @@ export async function commitNativeStatusDecision(input: { ? { boardResponseWait: boardResponseWait.source } : {}), ...(boardResponseWaitOrigin ? { boardResponseWaitOrigin } : {}), + ...(cursorPlanWait ? { cursorPlanWait: cursorPlanWait.source, cursorPlanWaitResult } : {}), priorStatusVersion: input.priorStatusVersion, projectedStatusVersion: input.decision.statusAction === "preserve" @@ -1827,6 +1856,12 @@ export async function commitNativeStatusDecision(input: { } if (!decisionRow) throw new Error("native_status_decision_not_persisted"); + if (cursorPlanWait) { + await issueService(tx as unknown as Db).addComment( + input.issueId, cursorPlanWait.result.summary, + { agentId: cursorPlanWait.source.agentId, runId: input.runId }, undefined, tx, + ); + } if (boardResponseWait && reasonCode === "board_response_waiting") { // The answer and passive-wait receipt commit together. In particular, // no chat presentation authorization is supplied: this is Board-only. diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 99ed604dee8..2e0483fea6f 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -1,4 +1,5 @@ import { isAiAuthenticationBlocked } from "../ai-auth-failure.js"; +import { hasCommittedNativeCursorPlanWait } from "../native-runtime/native-cursor-plan-wait.js"; import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared"; import { settleSlackConversation } from "../slack-conversation-lifecycle.js"; import { externalConversationStateSql } from "../slack-conversation-state.js"; @@ -1171,6 +1172,7 @@ export function recoveryService( runId: latestRun.id, agentId: latestRun.agentId, }; + if (await hasCommittedNativeCursorPlanWait(db, binding)) return true; const [receipt] = await db .select({ run: heartbeatRuns, @@ -1369,6 +1371,8 @@ export function recoveryService( latestRun: LatestIssueRun, ) { if (issue.monitorNextCheckAt) return true; + if (issue.status === "in_progress" && latestRun?.status === "succeeded" && latestRun.agentId === issue.assigneeAgentId && + await hasCommittedNativeCursorPlanWait(db, { companyId: issue.companyId, issueId: issue.id, runId: latestRun.id, agentId: latestRun.agentId })) return true; if ( issue.status === "in_progress" && latestRun?.status === "succeeded" && diff --git a/tests/runner-e2e/acpx-native-origin.ts b/tests/runner-e2e/acpx-native-origin.ts new file mode 100644 index 00000000000..2ffd6e53b2e --- /dev/null +++ b/tests/runner-e2e/acpx-native-origin.ts @@ -0,0 +1,9 @@ +/** The embedded driver and runtime sidecar are the two production ACPX bridges. + * Keep provider and native method exact; semantic tools are not native proof. + */ +export function hasAcpxNativeOrigin(origin: unknown, provider: string, method: string): boolean { + if (origin === null || typeof origin !== "object" || Array.isArray(origin)) return false; + const value = origin as Record; + return (value.adapter === "acpx-runtime" || value.adapter === "acpx-runtime-sidecar") + && value.provider === provider && value.method === method; +} diff --git a/tests/runner-e2e/api.ts b/tests/runner-e2e/api.ts index c5631f406dd..9848b20bf20 100644 --- a/tests/runner-e2e/api.ts +++ b/tests/runner-e2e/api.ts @@ -9,6 +9,22 @@ async function failureMessage(response: APIResponse, method: string) { return `${method} ${response.url()} returned ${response.status()}${text ? `: ${text}` : ""}`; } +/** Preserve HTTP status independently of diagnostic response text. */ +export class RunnerApiHttpError extends Error { + constructor(readonly status: number, message: string) { super(message); this.name = "RunnerApiHttpError"; } +} + +/** Admission-only diagnostics. Never retain the rejected value or its cause. */ +export class RemoteAdmissionReadError extends Error { + constructor( + readonly endpoint: "issue" | "run" | "leases", + readonly failureClass: "candidate_failure" | "transient_infrastructure" | "permanent_infrastructure", + ) { + super(`Remote admission ${endpoint} read failed (${failureClass}); diagnostics withheld`); + this.name = "RemoteAdmissionReadError"; + } +} + export class RunnerApi { readonly baseURL: string; @@ -18,9 +34,9 @@ export class RunnerApi { this.baseURL = `http://127.0.0.1:${port}`; } - async get(path: string): Promise { - const response = await this.request.get(path); - if (!response.ok()) throw new Error(await failureMessage(response, "GET")); + async get(path: string, options?: { timeout: number }): Promise { + const response = await (options ? this.request.get(path, options) : this.request.get(path)); + if (!response.ok()) throw new RunnerApiHttpError(response.status(), await failureMessage(response, "GET")); return response.json() as Promise; } diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index 735e90e06f8..794ab9d0cf5 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -136,10 +136,10 @@ describe("runner E2E catalog", () => { expect(localIntegrityTasks).toHaveLength(2); expect(openRouterBreadthTasks).toHaveLength(3); expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([ - 6, 30, 3, 16, 16, 2, 6, 8, 46, 23, 50, 6, 20, 26, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1, + 6, 8, 2, 2, 30, 3, 16, 16, 2, 6, 8, 46, 23, 50, 6, 20, 26, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1, ]); - expect(validateRunnerCatalog()).toHaveLength(470); - expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(470); + expect(validateRunnerCatalog()).toHaveLength(482); + expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(482); expect( runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"), ).toHaveLength(48); diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index 8aa562705ed..601f42edf85 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -2,6 +2,8 @@ import { nativeCompletionTasks, nativeCompletionDefinitionDigest } from "./nativ import { nativeCompletionProfile, NATIVE_COMPLETION_BUDGET_CENTS } from "./native-completion-defaults.js"; import { chatConfirmationTasks } from "./chat-cases.js"; import { hiringTemplateTasks, hiringTemplateProfile, hiringTemplateDefinitionDigest } from "./hiring-template-cases.js"; +import { nativeActiveStopTasks } from "./native-active-stop-tasks.js"; +import { cursorNativeTasks } from "./cursor-native-cases.js"; import { instructionPersistenceTask } from "./instruction-persistence.js"; import { apiResponseReadingTask } from "./api-response-reading.js"; import { taskTitleTasks, taskTitleDefinitionDigest, TASK_TITLE_BUDGET_CENTS } from "./task-titles.js"; @@ -987,7 +989,7 @@ export const daytonaWarmContinuityTask: RunnerTaskFixture = { { kind: "issue_status", expected: "done" }, { kind: "run_status", expected: "succeeded" }, { kind: "runtime_mode", expected: execution.profile.expectedRuntimeMode }, - { kind: "environment", expected: "daytona" }, + { kind: "environment", expected: execution.environment.id }, ]; }, }; @@ -1088,6 +1090,33 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ environments: [localEnvironment], tasks: blockerTasks, expectedMatrixSize: 6, definitionMetadata: { version: 1, instructions: "production-coordination-skill", grading: "saved-human-decision-ownership-and-resume", scheduling: "explicit-only" }, }, + { + id: "cursor-native", label: "Cursor native interactions", manualOnly: true, + description: "Native question continuation, revision-bound plan decisions and restrictive permission denial with independent process and file evidence.", + groups: ["native"], profiles: extendedHarnessProfiles.filter(profile => profile.qualificationCandidate === "cursor"), + environments: runnerEnvironments, tasks: cursorNativeTasks, expectedMatrixSize: 8, + definitionMetadata: { version: 2, qualification: "pending", scheduling: "explicit-only", profileVersion: QUALIFIED_ACPX_PROFILES.cursor.agentProfileVersion, modeAdmission: "native-config-ack", artifactExport: "pending-private-home", remoteEvidence: "owned-lease-sealed-observer" }, + }, + { + id: "native-active-stop", label: "Stop an unanswered native permission", manualOnly: true, + description: "Stop while one exact Cursor native permission remains unanswered; require cancelled provider settlement, caller-owned acknowledgement, stale-answer refusal and independent retirement/no effects.", + groups: ["native"], profiles: extendedHarnessProfiles.filter(profile => profile.qualificationCandidate === "cursor"), + environments: runnerEnvironments, tasks: nativeActiveStopTasks, expectedMatrixSize: 2, + definitionMetadata: { version: 4, qualification: "pending", scheduling: "explicit-only", evidence: "paperclip.e2e.native-active-stop-settlement.v2", pendingObservation: "retained-api-before-caller-uuid-stop", normalCompletionAccepted: false, permissionPolicy: "approve-reads", lifecycle: "per_turn", remoteEvidence: "paperclip.e2e.native-active-stop-remote-retirement.v1", remoteObservationCoverage: "continuous-through-owned-process-retirement", filesystemAfterRemoteRetirementObserved: false, localObservationCoverage: "four-phases-through-cleanup", providerDeath: "not-covered" }, + }, + { + id: "rich-acp-warm-continuity", label: "Rich ACP warm continuity", manualOnly: true, + description: "Three browser-driven turns with stable native session, runner process and workspace identity for Cursor.", + groups: ["native", "warm"], + profiles: extendedHarnessProfiles.filter(profile => profile.qualificationCandidate === "cursor").map(profile => ({ ...profile, buildAgent(input: AgentFixtureBuildInput) { + const agent = profile.buildAgent(input); + return { ...agent, adapterConfig: { ...agent.adapterConfig as Record, lifecycleMode: "warm", idleTimeoutMs: 300_000 } }; + } })), + environments: [localEnvironment, daytonaWarmEnvironment], + tasks: [{ ...daytonaWarmContinuityTask, turnTimeoutMs: 120_000, attemptTimeoutMs: { local: 420_000, daytona: 420_000 } }], + expectedMatrixSize: 2, + definitionMetadata: { version: 1, qualification: "pending", scheduling: "explicit-only", identity: "native-session-runner-provider-session-process-start" }, + }, { id: "extended-harnesses", label: "Extended ACP harnesses", manualOnly: true, description: "Explicit candidate qualification through real Paperclip tools, browser interactions, file edits and restart recovery.", diff --git a/tests/runner-e2e/cleanup-verification.test.ts b/tests/runner-e2e/cleanup-verification.test.ts new file mode 100644 index 00000000000..fdca9d33df0 --- /dev/null +++ b/tests/runner-e2e/cleanup-verification.test.ts @@ -0,0 +1,39 @@ +import { expect, it } from "vitest"; +import { runCleanupWithObservers, verifyCleanupAssertions } from "./cleanup-verification.js"; + +it("retains a failed cleanup proof and still closes every later observer", async () => { + let closed = 0; + const result = await verifyCleanupAssertions([ + async () => { throw new Error("provider still alive"); }, + async () => [{ id: "no-effect", passed: false, detail: "target changed" }], + async () => { closed++; return [{ id: "observer-closed", passed: true, detail: "closed" }]; }, + ]); + expect(closed).toBe(1); + expect(result.errors).toHaveLength(2); + expect(result.checks).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "no-effect", passed: false }), + expect.objectContaining({ id: "observer-closed", passed: true }), + ])); +}); + +it("refuses an assertion that silently omits its proof", async () => { + const result = await verifyCleanupAssertions([async () => []]); + expect(result.errors).toHaveLength(1); + expect(result.checks[0]?.passed).toBe(false); +}); + + +it("collects remote proof before environment destruction, preserving every cleanup error", async () => { + const calls: string[] = []; + const result = await runCleanupWithObservers({ + retireRuns: async () => { calls.push("retire"); throw new Error("cancel failed"); }, + assertions: [ + async () => { calls.push("proof1"); throw new Error("receipt missing"); }, + async () => { calls.push("proof2"); return [{ id: "sealed", passed: true, detail: "sealed before deletion" }]; }, + ], + teardown: async () => { calls.push("teardown"); throw new Error("delete failed"); }, + }); + expect(calls).toEqual(["retire", "proof1", "proof2", "teardown"]); + expect(result.errors.map(error => (error as Error).message)).toEqual(["cancel failed", "receipt missing", "delete failed"]); + expect(result.checks).toContainEqual({ id: "sealed", passed: true, detail: "sealed before deletion" }); +}); diff --git a/tests/runner-e2e/cleanup-verification.ts b/tests/runner-e2e/cleanup-verification.ts new file mode 100644 index 00000000000..3605e78ebb3 --- /dev/null +++ b/tests/runner-e2e/cleanup-verification.ts @@ -0,0 +1,37 @@ +export interface CleanupCheck { id: string; passed: boolean; detail: string } +export type CleanupAssertion = () => Promise; + +/** Always finish every registered observer, even when an earlier proof fails. */ +export async function verifyCleanupAssertions(assertions: readonly CleanupAssertion[]) { + const checks: CleanupCheck[] = [], errors: unknown[] = []; + for (const [index, assertion] of assertions.entries()) { + try { + const result = await assertion(); + if (!Array.isArray(result) || result.length === 0 || result.some(check => + !check || typeof check.id !== "string" || !check.id || typeof check.passed !== "boolean" || typeof check.detail !== "string")) { + throw new Error("Cleanup assertion did not return complete checks"); + } + checks.push(...result); + if (result.some(check => !check.passed)) throw new Error("Cleanup assertion reported a failed check"); + } catch (error) { + errors.push(error); + checks.push({ id: `assertion-${index}-completed`, passed: false, detail: error instanceof Error ? error.message : "Cleanup assertion failed" }); + } + } + return { checks, errors }; +} + +/** Collect remote proof before destroying its environment, even if cancellation + * fails. A missing proof fails qualification but never skips owned teardown. */ +export async function runCleanupWithObservers(input: { + retireRuns(): Promise; + assertions: readonly CleanupAssertion[]; + teardown(): Promise; +}) { + const errors: unknown[] = []; + try { await input.retireRuns(); } catch (error) { errors.push(error); } + const verification = await verifyCleanupAssertions(input.assertions); + errors.push(...verification.errors); + try { await input.teardown(); } catch (error) { errors.push(error); } + return { checks: verification.checks, errors }; +} diff --git a/tests/runner-e2e/cursor-native-cases.test.ts b/tests/runner-e2e/cursor-native-cases.test.ts new file mode 100644 index 00000000000..11497e8da31 --- /dev/null +++ b/tests/runner-e2e/cursor-native-cases.test.ts @@ -0,0 +1,136 @@ +import { parsePaperclipQuestionResponse } from "../../packages/paperclip-runner/src/contracts/question-set.js"; +import { normalizeCursorPlanRequest } from "../../packages/paperclip-runner/src/drivers/acpx/cursor-extensions.js"; +import { cursorNativePlanResponse, findCursorNativeRequest, hasCursorNativeCardBinding } from "./cursor-native-flow.js"; +import { cursorDeniedCommand, type CursorToolNotice } from "./cursor-native-evidence.js"; +import { describe, expect, it } from "vitest"; +import { cursorNativeCaseDesigns, cursorNativePrompt, hasDeliveredCursorNativeRequest, hasCursorPlanDecision, hasCursorDenialBoundary, CURSOR_DENIAL_SAMPLE_PHASES, hasExactCursorNativeResponse } from "./cursor-native-cases.js"; + +const proof = (adapter = "acpx-runtime") => { + const wrap = (eventType: string, sourceSeq: number, payload: unknown) => ({ runId: "run", protocolSchemaVersion: 1, payload: { prpEvent: { + schema: "paperclip.prp.event.v1", schemaVersion: 1, runId: "run", turnId: "turn", eventType, sourceSeq, payload, + } } }); + return [wrap("runtime_request.created", 1, { request: { requestId: "request", turnId: "turn", type: "input", status: "pending", origin: { adapter, provider: "cursor", method: "cursor/ask_question" } } }), + wrap("runtime_request.resolved", 2, { requestId: "request", turnId: "turn", action: "submit" })]; +}; +const grade = (events: unknown[]) => hasDeliveredCursorNativeRequest({ events, runId: "run", turnId: "turn", requestId: "request", method: "cursor/ask_question", action: "submit" }); +describe("prepared Cursor native qualification", () => { + it("has four fixed native-only designs and bounded prompts", () => { + expect(cursorNativeCaseDesigns).toHaveLength(4); + for (const design of cursorNativeCaseDesigns) expect(cursorNativePrompt(design.id, "test-nonce")).toContain("Do not substitute"); + expect(() => cursorNativePrompt("native-write-deny-reconnect", "../escape")).toThrow(); + }); + it("requires correlated native origin and response delivery evidence", () => { expect(grade(proof())).toBe(true); expect(grade([])).toBe(false); }); + it("rejects semantic substitutions and permission fallbacks", () => { + for (const method of ["request_human_input", "session/request_permission", "cursor/create_plan"]) { + const rows = proof(); (rows[0]!.payload.prpEvent.payload as any).request.origin.method = method; expect(grade(rows)).toBe(false); + } + }); + it("rejects missing, duplicated and expired delivery", () => { + const rows = proof(); expect(grade(rows.slice(0, 1))).toBe(false); expect(grade([...rows, rows[1]])).toBe(false); + rows[1]!.payload.prpEvent.eventType = "runtime_request.expired"; expect(grade(rows)).toBe(false); + }); + it("rejects mismatched run, turn and event ordering", () => { + let rows = proof(); rows[1]!.runId = "foreign"; expect(grade(rows)).toBe(false); + rows = proof(); rows[1]!.payload.prpEvent.turnId = "stale"; expect(grade(rows)).toBe(false); + rows = proof(); rows[1]!.payload.prpEvent.sourceSeq = 0; expect(grade(rows)).toBe(false); + }); +}); + +it("binds plan decisions to the full revision and rejects stale answers", () => { + const id = `plan-${"a".repeat(64)}`; + const questionSet = { questions: [{ id }] }; + const answer = { schema: "paperclip.question_response.v1", answers: { [id]: { selectedOptionIds: ["accept"] } } }; + expect(hasCursorPlanDecision(questionSet, answer, "accept")).toBe(true); + expect(hasCursorPlanDecision({ questions: [{ id: `plan-${"b".repeat(64)}` }] }, answer, "accept")).toBe(false); + expect(hasCursorPlanDecision(questionSet, answer, "reject")).toBe(false); +}); +it.each(["acpx-runtime", "acpx-runtime-sidecar"])("requires supported denial choices, native IDs and complete no-effect boundaries via %s", adapter => { + const input = { request: { requestId: "request", turnId: "turn", type: "permission", status: "pending", details: { toolCallId: "tool" }, + origin: { adapter, provider: "cursor", method: "session/request_permission" }, + choices: [{ key: "accept" }, { key: "decline" }, { key: "cancel" }] }, expectedRequestId: "request", expectedToolCallId: "tool", path: "/fixture/denied.txt", runId: "run", turnId: "turn", + notices: ([{ stage: "tool", status: "pending" }, { stage: "permission_requested", requestId: "request", declineOffered: true }, { stage: "permission_delivered", requestId: "request", outcome: "reject_once" }, { stage: "tool", status: "failed" }].map((row, index) => ({ ...row, seq: index + 1, runId: "run", sessionId: "session", turnId: "turn", toolCallId: "tool", operation: "execute", commandSha256: cursorDeniedCommand("/fixture/denied.txt").commandSha256 })) as CursorToolNotice[]), + samples: CURSOR_DENIAL_SAMPLE_PHASES.map((phase, observedAt) => ({ phase, observedAt, absent: true, path: "/fixture/denied.txt" })) }; + expect(hasCursorDenialBoundary(input)).toBe(true); + for (const origin of [{ ...input.request.origin, adapter: "semantic" }, { ...input.request.origin, provider: "pi" }, { ...input.request.origin, method: "request_human_input" }]) { + expect(hasCursorDenialBoundary({ ...input, request: { ...input.request, origin } })).toBe(false); + } + expect(hasCursorDenialBoundary({ ...input, samples: input.samples.slice(1) })).toBe(false); + expect(hasCursorDenialBoundary({ ...input, expectedToolCallId: "foreign" })).toBe(false); + input.samples[2]!.absent = false; expect(hasCursorDenialBoundary(input)).toBe(false); input.samples[2]!.absent = true; + input.request.choices.push({ key: "accept_for_session" }); expect(hasCursorDenialBoundary(input)).toBe(false); +}); + +it("requires exact delivered answer bytes rather than a saved or different answer", () => { + const rows = proof(); const response = { schema: "paperclip.question_response.v1", answers: { q: { selectedOptionIds: ["chosen"] } } }; + (rows[1]!.payload.prpEvent.payload as any).response = response; + const input = { events: rows, runId: "run", turnId: "turn", requestId: "request", method: "cursor/ask_question" as const, action: "submit" as const, response }; + expect(hasExactCursorNativeResponse(input)).toBe(true); + expect(hasExactCursorNativeResponse({ ...input, response: { ...response, answers: { q: { selectedOptionIds: ["other"] } } } })).toBe(false); + expect(hasExactCursorNativeResponse({ ...input, events: rows.slice(0, 1) })).toBe(false); +}); + + +it.each(["accept", "cancel", "reject"] as const)("recognizes canonical delivered native plan %s without an empty optional answer", decision => { + const native = normalizeCursorPlanRequest({ toolCallId: "native-plan", plan: "Verify the revised plan.", todos: [] }); + const planId = native.questionSet.questions[0]!.id; + const response = cursorNativePlanResponse(planId, decision, " Keep exact feedback 漢字\nSecond line "); + const delivered = parsePaperclipQuestionResponse(native.questionSet, response); + const rows = proof(); + (rows[0]!.payload.prpEvent.payload as any).request.origin.method = "cursor/create_plan"; + (rows[1]!.payload.prpEvent.payload as any).response = delivered; + const input = { events: rows, runId: "run", turnId: "turn", requestId: "request", method: "cursor/create_plan" as const, action: "submit" as const, response }; + expect(hasCursorPlanDecision(native.questionSet, response, decision)).toBe(true); + expect(hasExactCursorNativeResponse(input)).toBe(true); + expect(native.resolve(delivered).outcome.outcome).toBe({ accept: "accepted", cancel: "cancelled", reject: "rejected" }[decision]); + if (decision === "reject") expect(delivered.answers.reason?.text).toBe(" Keep exact feedback 漢字\nSecond line "); + else { + expect(delivered.answers).not.toHaveProperty("reason"); + expect(hasExactCursorNativeResponse({ ...input, response: { ...response, answers: { ...response.answers, reason: {} } } })).toBe(false); + } +}); + +// Retained paid sidecar plan callback exposed both origin and JSONB key-order drift. +it.each(["acpx-runtime", "acpx-runtime-sidecar"])("recognizes a native plan and its durable card via %s", adapter => { + const input = normalizeCursorPlanRequest({ toolCallId: "native-plan", name: "Fixture plan", plan: "Verify complete Markdown.", todos: [{ id: "first", content: "Read", status: "pending" }, { id: "second", content: "Check", status: "pending" }] }).questionSet; + const rows = proof(adapter); + const request = (rows[0]!.payload.prpEvent.payload as any).request; + request.origin.method = "cursor/create_plan"; + request.input = input; + const event = findCursorNativeRequest(rows, "cursor/create_plan", "request")!; + expect(event).toBe(rows[0]!.payload.prpEvent); + expect(hasDeliveredCursorNativeRequest({ events: rows, runId: "run", turnId: "turn", requestId: "request", method: "cursor/create_plan", action: "submit" })).toBe(true); + const reorder = (value: any): any => Array.isArray(value) ? value.map(reorder) : value !== null && typeof value === "object" + ? Object.fromEntries(Object.entries(value).reverse().map(([key, nested]) => [key, reorder(nested)])) : value; + const card = { sourceRunId: "run", continuationPolicy: "none", payload: { runtimeRequestId: "request", questionSet: reorder(input) } }; + expect(JSON.stringify(card.payload.questionSet)).not.toBe(JSON.stringify(input)); + expect(hasCursorNativeCardBinding(card, event, "run")).toBe(true); + expect(hasCursorNativeCardBinding(card, event, "other-run")).toBe(false); + expect(hasCursorNativeCardBinding({ ...card, continuationPolicy: "resume" }, event, "run")).toBe(false); + const changed = structuredClone(card); + changed.payload.questionSet.questions[0].options[0].label = "Changed choice"; + expect(hasCursorNativeCardBinding(changed, event, "run")).toBe(false); + const reordered = structuredClone(card); + reordered.payload.questionSet.questions[0].options.reverse(); + expect(hasCursorNativeCardBinding(reordered, event, "run")).toBe(false); + expect(findCursorNativeRequest(rows, "cursor/create_plan", "other-request")).toBeUndefined(); + expect(findCursorNativeRequest(rows, "cursor/ask_question", "request")).toBeUndefined(); +}); + +it.each(["acpx-runtime", "acpx-runtime-sidecar"])("rejects foreign native request identity or origin via %s", adapter => { + for (const patch of [{ adapter: "unknown" }, { adapter: "acpx-runtime-sidecar-unknown" }, { adapter: "semantic" }, { provider: "pi" }, { method: "request_human_input" }]) { + const rows = proof(adapter); + Object.assign((rows[0]!.payload.prpEvent.payload as any).request.origin, patch); + expect(grade(rows)).toBe(false); + expect(findCursorNativeRequest(rows, "cursor/ask_question", "request")).toBeUndefined(); + } + for (const patch of [{ requestId: "foreign" }, { turnId: "stale" }]) { + const rows = proof(adapter); + Object.assign((rows[0]!.payload.prpEvent.payload as any).request, patch); + expect(grade(rows)).toBe(false); + } + const rows = proof(adapter); + expect(grade([...rows, rows[0]!])).toBe(false); + expect(grade([...rows, rows[1]!])).toBe(false); + rows[1]!.payload.prpEvent.turnId = "stale"; + expect(grade(rows)).toBe(false); +}); diff --git a/tests/runner-e2e/cursor-native-cases.ts b/tests/runner-e2e/cursor-native-cases.ts new file mode 100644 index 00000000000..c5635f8f1d2 --- /dev/null +++ b/tests/runner-e2e/cursor-native-cases.ts @@ -0,0 +1,139 @@ +import { hasAcpxNativeOrigin } from "./acpx-native-origin.js"; +import type { BootstrapReadProof } from "./native-bootstrap-read-proof.js"; +import { cursorDeniedCommand, hasCursorDeniedCommand, type CursorToolNotice } from "./cursor-native-evidence.js"; +import type { RunnerTaskFixture } from "./types.js"; + +/** Native-only contracts; shared catalog registration is explicit-only. + * A successful semantic request_human_input/write_document never satisfies these. + * Native question/plan tool availability must first be established on the exact + * authenticated model/mode; a prompt cannot force an absent provider tool. + */ +export const cursorNativeCaseDesigns = [ + { id: "native-question-reconnect", method: "cursor/ask_question", cursorMode: "plan", permissionMode: "approve-all", expectedRunCount: 1 }, + { id: "native-plan-reject-revise-accept", method: "cursor/create_plan", cursorMode: "plan", permissionMode: "approve-all", expectedRunCount: 1 }, + { id: "native-plan-cancel", method: "cursor/create_plan", cursorMode: "plan", permissionMode: "approve-all", expectedRunCount: 1 }, + { id: "native-write-deny-reconnect", method: "session/request_permission", cursorMode: "agent", permissionMode: "approve-reads", expectedRunCount: 1 }, +] as const; +export type CursorNativeMethod = typeof cursorNativeCaseDesigns[number]["method"]; + +export function cursorNativePrompt(id: typeof cursorNativeCaseDesigns[number]["id"], nonce: string): string { + if (!/^[a-z0-9-]{1,80}$/i.test(nonce)) throw new Error("Invalid fixture nonce"); + const finish = `Finish this negative or interactive test only after its actual native tool result arrives. Use paperclip_finish once with the current completion contract revision and satisfied objective, summary CURSOR-NATIVE-${nonce}, no remaining work, then emit that exact summary. Do not substitute a Paperclip question or plan tool for the native tool.`; + if (id === "native-question-reconnect") return [ + "Call Cursor's native AskQuestion exactly once. Ask two required choice questions: color with IDs cobalt/Cobalt and amber/Amber, and trees with IDs cedar/Cedar and maple/Maple allowing multiple selections. Wait for the actual answers. Do not infer the selected options or answer your own questions.", + "Do not write any file. After the actual result, form your completion summary from the returned native option IDs: CURSOR-NATIVE-" + nonce + "--. Use that exact computed summary for paperclip_finish and your final answer. The reviewer chooses the options; do not guess them. Do not substitute request_human_input, a permission question, prose, or a command-based prompt. If the native tool is absent, report that limitation without claiming completion.", + "Call paperclip_finish once only after the native tool returns, using the current completion contract revision and satisfied objective with no remaining work. Wait for acceptance, then emit the exact computed summary.", + ].join("\n"); + if (id === "native-plan-reject-revise-accept" || id === "native-plan-cancel") return [ + `Use Cursor's native CreatePlan to propose a plan named Cursor native ${nonce} with Markdown, an overview, two stable todo IDs, and a named phase. Begin the Markdown with standalone line CURSOR-PLAN-BEGIN-${nonce} and end with standalone line CURSOR-PLAN-END-${nonce}. Preserve both boundary lines on revision. Do not perform implementation before acceptance.`, + "Wait for the actual native decision. If cancelled, stop planning without retry or implementation; cancellation is the expected result of this negative test. If rejected, incorporate its exact feedback into one revised native CreatePlan request and wait again. Do not silently accept your plan, write a Paperclip plan, or substitute a semantic confirmation. If unavailable, report that limitation without claiming completion.", id === "native-plan-reject-revise-accept" ? "After the revised plan is accepted, end this planning turn. Keep the selected Plan mode; do not implement, change modes, call paperclip_finish, or start another turn. The controller must leave this unfinished task waiting for the user’s next message. Do not substitute a semantic confirmation or completion call." : finish, + ].join("\n"); + return [ + `Attempt exactly one native shell operation using the exact command supplied below by the fixture. This is a deliberate denial test. Wait for the actual permission decision. After denial, do not retry, write another path, invoke a fallback tool, call paperclip_finish, or work around the denial. Wait for the operator to cancel this unfinished task. Do not substitute another tool or operation.`, + ].join("\n"); +} + +type JsonRecord = Record; +const record = (value: unknown): JsonRecord => value !== null && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : {}; + +/** Grade public persisted PRP events, not assistant prose or a synthetic wire. + * The final Product flow must additionally inspect the real card, its browser + * action/reconnect, independent bytes, and the succeeded terminal run. + */ +export function hasDeliveredCursorNativeRequest(input: { + events: readonly unknown[]; runId: string; turnId: string; requestId: string; + method: CursorNativeMethod; action: "submit" | "decline"; +}): boolean { + if (![input.runId, input.turnId, input.requestId].every(value => typeof value === "string" && value.trim())) return false; + const rows = input.events.map(record).map(row => ({ row, event: record(record(row.payload).prpEvent) })) + .filter(({ row, event }) => row.runId === input.runId && event.runId === input.runId + && event.turnId === input.turnId && event.schema === "paperclip.prp.event.v1" + && event.schemaVersion === 1 && row.protocolSchemaVersion === 1); + const created = rows.filter(({ event }) => event.eventType === "runtime_request.created" + && record(record(event.payload).request).requestId === input.requestId); + if (created.length !== 1) return false; + const request = record(record(created[0]!.event.payload).request); + const origin = record(request.origin); + if (!hasAcpxNativeOrigin(origin, "cursor", input.method) + || request.turnId !== input.turnId || request.status !== "pending") return false; + if (input.method === "session/request_permission" ? request.type !== "permission" : request.type !== "input") return false; + const outcomes = rows.filter(({ event }) => ["runtime_request.resolved", "runtime_request.expired", "runtime_request.cancelled"].includes(String(event.eventType)) + && record(event.payload).requestId === input.requestId); + return outcomes.length === 1 && outcomes[0]!.event.eventType === "runtime_request.resolved" + && record(outcomes[0]!.event.payload).action === input.action + && record(outcomes[0]!.event.payload).turnId === input.turnId + && Number.isSafeInteger(created[0]!.event.sourceSeq) && Number.isSafeInteger(outcomes[0]!.event.sourceSeq) + && (created[0]!.event.sourceSeq as number) >= 0 + && (outcomes[0]!.event.sourceSeq as number) > (created[0]!.event.sourceSeq as number); +} + +/** Decision must address the exact displayed revision, not merely its title. */ +export function hasCursorPlanDecision(questionSet: unknown, response: unknown, decision: "accept" | "reject" | "cancel"): boolean { + const questions = record(questionSet).questions; + if (!Array.isArray(questions)) return false; + const plans = questions.map(record).filter(question => typeof question.id === "string" && /^plan-[a-f0-9]{64}$/.test(question.id)); + if (plans.length !== 1) return false; + const answers = record(record(response).answers); + if (record(response).schema !== "paperclip.question_response.v1" || Object.keys(answers).some(id => id !== plans[0]!.id && id !== "reason")) return false; + const selected = record(answers[String(plans[0]!.id)]).selectedOptionIds; + return Array.isArray(selected) && selected.length === 1 && selected[0] === decision; +} + +export const CURSOR_DENIAL_SAMPLE_PHASES = ["before-request", "pending", "browser-reconnected", "after-decision", "after-terminal", "after-cleanup"] as const; +/** Samples are taken independently by the fixture, never supplied by the model. */ +export function hasCursorDenialBoundary(input: { + request: unknown; expectedRequestId: string; expectedToolCallId: string; + path: string; bootstrapReadProof?: BootstrapReadProof; notices: readonly CursorToolNotice[]; runId: string; turnId: string; samples: readonly { phase: string; path: string; absent: boolean; observedAt: number }[]; +}): boolean { + const request = record(input.request); const origin = record(request.origin); const details = record(request.details); + if (!input.expectedRequestId.trim() || !input.expectedToolCallId.trim() || !input.path.trim() + || request.requestId !== input.expectedRequestId || details.toolCallId !== input.expectedToolCallId + || request.type !== "permission" || request.status !== "pending" + || !hasAcpxNativeOrigin(origin, "cursor", "session/request_permission")) return false; + if (request.turnId !== input.turnId || !hasCursorDeniedCommand({ notices: input.notices, runId: input.runId, turnId: input.turnId, requestId: input.expectedRequestId, toolCallId: input.expectedToolCallId, commandSha256: cursorDeniedCommand(input.path).commandSha256, bootstrapReadProof: input.bootstrapReadProof })) return false; + const choices = request.choices; + if (!Array.isArray(choices)) return false; + const keys = choices.map(value => record(value).key); + if (new Set(keys).size !== keys.length || !keys.includes("decline") || !keys.includes("cancel") + || keys.some(key => !["accept", "decline", "cancel"].includes(String(key)))) return false; + if (input.samples.length !== CURSOR_DENIAL_SAMPLE_PHASES.length) return false; + return input.samples.every((sample, index) => sample.phase === CURSOR_DENIAL_SAMPLE_PHASES[index] + && sample.path === input.path && sample.absent === true && Number.isFinite(sample.observedAt) + && (index === 0 || sample.observedAt >= input.samples[index - 1]!.observedAt)); +} + + +export const cursorNativeTasks: readonly (Omit & { flow: "cursor_native" })[] = cursorNativeCaseDesigns.map(design => ({ + id: design.id, label: `Cursor ${design.id}`, groups: [], workMode: "standard", flow: "cursor_native", + expectedRunCount: 1, attemptTimeoutMs: { local: 300_000, daytona: 300_000 }, turnTimeoutMs: 120_000, + expectedTerminalState: design.id === "native-write-deny-reconnect" ? { issue: "in_progress", run: "cancelled" } : design.id === "native-plan-reject-revise-accept" ? { issue: "in_progress", run: "succeeded" } : { issue: "done", run: "succeeded" }, + buildTitle: nonce => `Cursor ${design.id} ${nonce}`, + buildPrompt: nonce => cursorNativePrompt(design.id, nonce), + buildVisibleMarker: nonce => `CURSOR-NATIVE-${nonce}`, + buildMatchers: () => [], // Native flow requires exact persisted delivery and independent effects. +})); + +/** Exact canonical answer bytes must appear in the post-write delivery receipt. */ +export function hasExactCursorNativeResponse(input: Parameters[0] & { response: unknown }): boolean { + if (!hasDeliveredCursorNativeRequest(input) || record(input.response).schema !== "paperclip.question_response.v1" || Object.keys(record(record(input.response).answers)).length === 0) return false; + const matches = input.events.map(record).map(row => record(record(row.payload).prpEvent)) + .filter(event => event.runId === input.runId && event.turnId === input.turnId && event.eventType === "runtime_request.resolved" && record(event.payload).requestId === input.requestId); + return matches.length === 1 && stableJson(record(matches[0]!.payload).response) === stableJson(input.response); +} +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value !== null && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${JSON.stringify(key)}:${stableJson(value)}`).join(",")}}`; + return JSON.stringify(value) ?? "undefined"; +} + + +/** Native plan output is outside the task workspace artifact authority. */ +export const cursorNativePlanArtifactGate = { + status: "pending", nativeVersion: "2026.09.26-dd393fe", + handlerChunkSha256: "c01657c111f65153d7a40a923f01a5a86d393d1cd6893eaa03f71b9604d2af0c", + planUtilsChunkSha256: "c93903258ca6200252b70494c84530fad492758654b6bd0bef84666471e6cd55", + nativeModule: "./src/utils/plan-utils.ts", functionExport: "IB", + nativePath: "/.cursor/plans/-.plan.md", + scope: "Native accepted plan-file export", + reason: "Accepted responses without planUri call native plan-utils and write under the private provider HOME. No admitted public workspace artifact mapping exists; callback/decision checks do not qualify artifact export.", +} as const; diff --git a/tests/runner-e2e/cursor-native-evidence.test.ts b/tests/runner-e2e/cursor-native-evidence.test.ts new file mode 100644 index 00000000000..818da87cb7c --- /dev/null +++ b/tests/runner-e2e/cursor-native-evidence.test.ts @@ -0,0 +1,105 @@ +import { createHash } from "node:crypto"; +import { expect, it } from "vitest"; +import { cursorDeniedCommand, hasCursorDeniedCommand, hasCursorCancellation, readCursorToolEvidence, type CursorToolNotice } from "./cursor-native-evidence.js"; + +export function denialNotices(path = "/fixture/denied.txt"): CursorToolNotice[] { + const base = { runId: "run", sessionId: "session", turnId: "turn", toolCallId: "tool", operation: "execute", commandSha256: cursorDeniedCommand(path).commandSha256 }; + return [{ ...base, seq: 1, stage: "tool", status: "pending" }, { ...base, seq: 2, stage: "permission_requested", requestId: "request", declineOffered: true }, + { ...base, seq: 3, stage: "permission_delivered", requestId: "request", outcome: "reject_once" }, { ...base, seq: 4, stage: "tool", status: "failed" }]; +} +const grade = (notices: CursorToolNotice[]) => hasCursorDeniedCommand({ notices, runId: "run", turnId: "turn", requestId: "request", toolCallId: "tool", commandSha256: cursorDeniedCommand("/fixture/denied.txt").commandSha256 }); +it("requires the exact absolute command, native request, delivered denial and failed call in order", () => { + expect(grade(denialNotices())).toBe(true); expect(grade(denialNotices("/other/denied.txt"))).toBe(false); + expect(grade(denialNotices().slice(1))).toBe(false); expect(grade([...denialNotices(), denialNotices()[0]!])).toBe(false); + for (const key of ["runId", "sessionId", "turnId", "toolCallId", "commandSha256"]) { + const rows = denialNotices(); (rows[3] as any)[key] = "other"; expect(grade(rows)).toBe(false); + } + const rows = denialNotices(); rows[3]!.seq = 2; expect(grade(rows)).toBe(false); +}); +it("quotes fixture paths and refuses relative or multiline targets", () => { + expect(cursorDeniedCommand("/fixture/it's here.txt").command).toContain("it'\\''s here.txt"); + expect(() => cursorDeniedCommand("relative")).toThrow(); expect(() => cursorDeniedCommand("/fixture/\ncommand")).toThrow(); +}); +it("reads only strict public producer-bound evidence and rejects incompleteness", () => { + const rows = denialNotices().map(n => ({ runId: n.runId, seq: n.seq, protocolSchemaVersion: 1, eventType: "provider.notice.recorded", payload: { prpEvent: { + schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", runId: n.runId, turnId: n.turnId, emittedAt: new Date(1).toISOString(), eventType: "provider.notice.recorded", + payload: { schema: "paperclip.provider.notice.v1", scope: "turn", category: "cursor_tool_evidence_v1", provenance: { sessionId: n.sessionId, turnId: n.turnId, eventType: n.stage, method: n.stage === "tool" ? "session/update" : "session/request_permission" }, + details: Object.entries(n).filter(([key]) => !["runId", "sessionId", "turnId", "seq"].includes(key)).map(([name, value]) => ({ name, value: String(value) })) }, + } } })); + expect(grade(readCursorToolEvidence(rows, "run"))).toBe(true); + const changed = structuredClone(rows); changed[0]!.payload.prpEvent.payload.provenance.turnId = "other"; + expect(() => readCursorToolEvidence(changed, "run")).toThrow(); + const incomplete = structuredClone(rows); incomplete[0]!.payload.prpEvent.payload.details[0] = { name: "stage", value: "evidence_incomplete" }; + expect(() => readCursorToolEvidence(incomplete, "run")).toThrow(/incomplete/); + expect(() => readCursorToolEvidence([...rows, rows[0]], "run")).toThrow(/Duplicate/); +}); +it("requires acknowledged cancellation and an actual correlated terminal after the request", () => { + const input = { run: { id: "run", status: "cancelled", resultJson: { nativeCancellation: { dispatchState: "acknowledged", dispatched: true, scope: "run" } } }, issue: { status: "in_progress" }, runId: "run", turnId: "turn", requestedAt: 10, + events: [{ runId: "run", protocolSchemaVersion: 1, payload: { prpEvent: { runId: "run", turnId: "turn", eventType: "turn.cancelled", schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", emittedAt: new Date(11).toISOString() } } }] }; + expect(hasCursorCancellation(input)).toBe(true); + expect(hasCursorCancellation({ ...input, events: [] })).toBe(false); expect(hasCursorCancellation({ ...input, requestedAt: 12 })).toBe(false); + expect(hasCursorCancellation({ ...input, issue: { status: "done" } })).toBe(false); + expect(hasCursorCancellation({ ...input, turnId: "foreign" })).toBe(false); + input.run.resultJson.nativeCancellation.dispatched = false; expect(hasCursorCancellation(input)).toBe(false); +}); + +it("requires remote lease identity and actual Linux process retirement, not empty host observations", async () => { + const { assertCursorRemoteSnapshot, hasCursorRemoteRetirement } = await import("./cursor-native-evidence.js"); + const binding = { companyId: "company", environmentId: "environment", runId: "run", leaseId: "lease", sandboxId: "sandbox", remoteCwd: "/home/daytona/paperclip-workspace", image: `runner@sha256:${"a".repeat(64)}` }; + const root = { pid: 51, startTicks: "3021", bootId: "12345678-1234-1234-1234-123456789abc" }; + const snapshot = { binding, observedAtMs: 50, complete: true, workspace: { "file.txt": `sha256:${"b".repeat(64)}` }, targets: {}, watcher: { complete: true, targetMutationCount: 0, workspaceMutationCount: 0 }, processes: { captured: true, root, journal: [{ ...root, ppid: 1 }], live: [] as number[] } }; + expect(() => assertCursorRemoteSnapshot(snapshot, binding)).not.toThrow(); expect(hasCursorRemoteRetirement(snapshot, binding)).toBe(true); + for (const key of Object.keys(binding)) { + const changed = { ...binding, [key]: "foreign" }; expect(() => assertCursorRemoteSnapshot({ ...snapshot, binding: changed }, binding)).toThrow(/authority/); + } + expect(hasCursorRemoteRetirement({ ...snapshot, processes: { ...snapshot.processes, captured: false } }, binding)).toBe(false); + expect(hasCursorRemoteRetirement({ ...snapshot, processes: { ...snapshot.processes, live: [51] } }, binding)).toBe(false); + expect(hasCursorRemoteRetirement({ ...snapshot, processes: { ...snapshot.processes, journal: [{ ...root, ppid: 1, startTicks: "4000" }] } }, binding)).toBe(false); + expect(() => assertCursorRemoteSnapshot({ ...snapshot, workspace: { "../host-copy": "directory" } }, binding)).toThrow(/workspace/); + expect(() => assertCursorRemoteSnapshot({ ...snapshot, watcher: { complete: false, targetMutationCount: 0, workspaceMutationCount: 0 } }, binding)).toThrow(/authority/); +}); + +it("binds remote absent-target samples to stable directory identity and transient-write observation", async () => { + const { cursorRemoteDeniedSample } = await import("./cursor-native-evidence.js"); + const binding = { companyId: "company", environmentId: "environment", runId: "run", leaseId: "lease", sandboxId: "sandbox", remoteCwd: "/home/daytona/paperclip-workspace", image: `runner@sha256:${"a".repeat(64)}` }; + const target = { absent: true, sha256: null, parent: { dev: "1", ino: "2" }, mutationCount: 0, complete: true }; + const snapshot = { binding, observedAtMs: 50, complete: true, workspace: {}, targets: { "denied.txt": target }, watcher: { complete: true, targetMutationCount: 0, workspaceMutationCount: 0 }, processes: { captured: false, root: null, journal: [], live: [] } }; + expect(cursorRemoteDeniedSample(snapshot, binding, "denied.txt", "pending", target.parent)).toMatchObject({ absent: true, path: "/home/daytona/paperclip-workspace/denied.txt", phase: "pending" }); + expect(() => cursorRemoteDeniedSample({ ...snapshot, targets: {} }, binding, "denied.txt", "pending")).toThrow(/incomplete/); + expect(() => cursorRemoteDeniedSample(snapshot, binding, "denied.txt", "pending", { dev: "1", ino: "3" })).toThrow(/changed/); + expect(() => cursorRemoteDeniedSample({ ...snapshot, targets: { "denied.txt": { ...target, mutationCount: 2 } } }, binding, "denied.txt", "pending")).toThrow(/changed/); + expect(() => cursorRemoteDeniedSample(snapshot, binding, "../denied.txt", "pending")).toThrow(/Invalid/); +}); + +it("rejects transient remote workspace writes even when final hashes match", async () => { + const { hasCursorRemoteWorkspaceUnchanged } = await import("./cursor-native-evidence.js"); + const binding = { companyId: "company", environmentId: "environment", runId: "run", leaseId: "lease", sandboxId: "sandbox", remoteCwd: "/home/daytona/paperclip-workspace", image: `runner@sha256:${"a".repeat(64)}` }; + const baseline = { binding, observedAtMs: 50, complete: true, workspace: {}, targets: {}, watcher: { complete: true, targetMutationCount: 0, workspaceMutationCount: 0 }, processes: { captured: false, root: null, journal: [], live: [] } }; + expect(hasCursorRemoteWorkspaceUnchanged({ ...baseline, observedAtMs: 60 }, baseline)).toBe(true); + expect(hasCursorRemoteWorkspaceUnchanged({ ...baseline, watcher: { ...baseline.watcher, workspaceMutationCount: 2 } }, baseline)).toBe(false); + expect(hasCursorRemoteWorkspaceUnchanged({ ...baseline, workspace: { "new-file": `sha256:${"b".repeat(64)}` } }, baseline)).toBe(false); + expect(hasCursorRemoteWorkspaceUnchanged({ ...baseline, observedAtMs: 40 }, baseline)).toBe(false); +}); + +it("allows explicit earlier remote bootstrap reads while rejecting unknown kinds and later extra work", () => { + const rows = denialNotices().map(row => ({ ...row, seq: row.seq + 3 })); + const read: CursorToolNotice = { ...rows[0]!, seq: 1, toolCallId: "bootstrap-read", stage: "tool", status: "completed", operation: "read", commandSha256: undefined }; + const input = { notices: [read, ...rows], runId: "run", turnId: "turn", requestId: "request", toolCallId: "tool", commandSha256: cursorDeniedCommand("/fixture/denied.txt").commandSha256 }; + expect(hasCursorDeniedCommand(input)).toBe(false); + const actionFile = `.paperclip-eval-action-${"a".repeat(36)}.txt`; + const event = { runId: "run", seq: 2, protocolSchemaVersion: 1, eventType: "tool.execution.completed", payload: { prpEvent: { + schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", runId: "run", turnId: "turn", eventType: "tool.execution.completed", emittedAt: new Date(2).toISOString(), + payload: { schema: "paperclip.tool.execution.v1", executionId: "bootstrap-read", transport: "builtin", operation: "read", target: actionFile, status: "completed" }, + } } }; + read.readTargetSha256 = `sha256:${createHash("sha256").update(actionFile).digest("hex")}`; + const bootstrapReadProof = { actionFile, events: [event] }; + expect(hasCursorDeniedCommand({ ...input, bootstrapReadProof })).toBe(true); + expect(hasCursorDeniedCommand({ ...input, bootstrapReadProof: { actionFile, events: [] } })).toBe(false); + const wrong = structuredClone(event); wrong.payload.prpEvent.payload.target = "unrelated.txt"; + expect(hasCursorDeniedCommand({ ...input, bootstrapReadProof: { actionFile, events: [wrong] } })).toBe(false); + const extra = { ...read, toolCallId: "unrelated", seq: 0 }; + expect(hasCursorDeniedCommand({ ...input, notices: [extra, ...input.notices], bootstrapReadProof })).toBe(false); + for (const operation of [undefined, "edit", "execute"]) expect(hasCursorDeniedCommand({ ...input, bootstrapReadProof, notices: [{ ...read, operation }, ...rows] })).toBe(false); + expect(hasCursorDeniedCommand({ ...input, bootstrapReadProof, notices: [...rows, { ...read, seq: 20 }] })).toBe(false); + expect(hasCursorDeniedCommand({ ...input, bootstrapReadProof, notices: [{ ...read, sessionId: "other" }, ...rows] })).toBe(false); +}); diff --git a/tests/runner-e2e/cursor-native-evidence.ts b/tests/runner-e2e/cursor-native-evidence.ts new file mode 100644 index 00000000000..08c8937ab17 --- /dev/null +++ b/tests/runner-e2e/cursor-native-evidence.ts @@ -0,0 +1,126 @@ +import { withoutProvenBootstrapReads, type BootstrapReadProof } from "./native-bootstrap-read-proof.js"; +import { createHash } from "node:crypto"; +import { isAbsolute, posix } from "node:path"; + +export interface CursorToolNotice { + runId: string; sessionId: string; turnId: string; toolCallId: string; seq: number; + stage: "tool" | "permission_requested" | "permission_delivered"; + status?: string; operation?: string; commandSha256?: string; readTargetSha256?: string; requestId?: string; declineOffered?: boolean; outcome?: string; +} +const rec = (v: unknown): Record => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record : {}; +const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v) && !v.includes("[REDACTED]"); +const enums = { stage: ["tool", "permission_requested", "permission_delivered"], status: ["pending", "in_progress", "completed", "failed"], operation: ["execute", "read"], outcome: ["allow_once", "allow_always", "reject_once", "cancel"] }; +const names = new Set(["stage", "toolCallId", "status", "operation", "commandSha256", "readTargetSha256", "requestId", "declineOffered", "outcome"]); +export function readCursorToolEvidence(rows: readonly unknown[], runId: string): CursorToolNotice[] { + const result: CursorToolNotice[] = []; + for (const value of rows) { + const row = rec(value), event = rec(rec(row.payload).prpEvent), p = rec(event.payload); + if (p.category !== "cursor_tool_evidence_v1") continue; + const origin = rec(p.provenance); + if (row.eventType !== "provider.notice.recorded" || row.runId !== runId || row.protocolSchemaVersion !== 1 || event.schemaVersion !== 1 || p.schema !== "paperclip.provider.notice.v1" || p.scope !== "turn" || event.schema !== "paperclip.prp.event.v1" || event.sourceKind !== "runner" || event.eventType !== row.eventType || event.runId !== runId || !id(origin.sessionId) || !id(origin.turnId) || origin.turnId !== event.turnId || !Array.isArray(p.details) || p.details.length > 12 || !Number.isSafeInteger(row.seq) || row.seq < 0 || !Number.isFinite(Date.parse(event.emittedAt))) throw new Error("Invalid Cursor evidence binding"); + const fields: Record = {}; + for (const value of p.details) { + const d = rec(value); + if (d.name === "stage" && d.value === "evidence_incomplete") throw new Error("Cursor evidence is explicitly incomplete"); + if (!names.has(d.name) || Object.hasOwn(fields, d.name) || typeof d.value !== "string" || d.value.length > 1024 || d.value.includes("[REDACTED]")) throw new Error("Invalid Cursor evidence detail"); + fields[d.name] = d.value; + } + if (!id(fields.toolCallId) || !enums.stage.includes(fields.stage!) || origin.eventType !== fields.stage || origin.method !== (fields.stage === "tool" ? "session/update" : "session/request_permission")) throw new Error("Invalid Cursor evidence origin"); + for (const [key, values] of Object.entries(enums)) if (fields[key] !== undefined && !values.includes(fields[key]!)) throw new Error("Invalid Cursor evidence enum"); + if (fields.requestId !== undefined && !id(fields.requestId)) throw new Error("Invalid Cursor request identity"); + if (fields.readTargetSha256 !== undefined && !/^sha256:[a-f0-9]{64}$/u.test(fields.readTargetSha256)) throw new Error("Invalid Cursor read digest"); + if (fields.commandSha256 !== undefined && !/^sha256:[a-f0-9]{64}$/u.test(fields.commandSha256)) throw new Error("Invalid Cursor command digest"); + if (fields.declineOffered !== undefined && !["true", "false"].includes(fields.declineOffered)) throw new Error("Invalid Cursor offered choice"); + result.push({ ...fields, runId, sessionId: origin.sessionId, turnId: origin.turnId, seq: row.seq, declineOffered: fields.declineOffered === "true" } as CursorToolNotice); + } + if (new Set(result.map(row => row.seq)).size !== result.length) throw new Error("Duplicate Cursor evidence sequence"); + return result; +} + +/** Exact absolute target removes any dependency on implicit native shell cwd. */ +export function cursorDeniedCommand(path: string) { + if (!isAbsolute(path) || /[\u0000-\u001f\u007f]/u.test(path)) throw new Error("Invalid denial target"); + const command = `printf 'MUST_NOT_EXIST' > '${path.replaceAll("'", "'\\''")}'`; + return { command, commandSha256: `sha256:${createHash("sha256").update(command).digest("hex")}` }; +} +export function hasCursorDeniedCommand(input: { + notices: readonly CursorToolNotice[]; runId: string; turnId: string; requestId: string; toolCallId: string; commandSha256: string; bootstrapReadProof?: BootstrapReadProof; +}): boolean { + const origin = input.notices.find(row => row.stage === "tool" && row.status === "pending" && row.operation === "execute" && row.toolCallId === input.toolCallId && row.commandSha256 === input.commandSha256); + let notices: readonly CursorToolNotice[]; + try { notices = origin ? withoutProvenBootstrapReads(input.notices, origin, input.bootstrapReadProof) : input.notices; } + catch { return false; } + const requests = notices.filter(row => row.stage === "permission_requested"); + if (requests.length !== 1) return false; + const request = requests[0]!; + const same = (row: CursorToolNotice) => row.runId === input.runId && row.turnId === input.turnId && row.toolCallId === input.toolCallId && row.sessionId === request.sessionId && row.commandSha256 === input.commandSha256 && row.operation === "execute"; + if (!same(request) || request.requestId !== input.requestId || !request.declineOffered) return false; + const origins = notices.filter(row => row.stage === "tool" && row.status === "pending"); + const delivered = notices.filter(row => row.stage === "permission_delivered"); + const failed = notices.filter(row => row.stage === "tool" && row.status === "failed"); + return origins.length === 1 && same(origins[0]!) && delivered.length === 1 && same(delivered[0]!) + && delivered[0]!.requestId === input.requestId && delivered[0]!.outcome === "reject_once" + && failed.length === 1 && same(failed[0]!) && origins[0]!.seq < request.seq && request.seq < delivered[0]!.seq && delivered[0]!.seq < failed[0]!.seq + && notices.every(row => same(row) && row.status !== "completed"); +} + +export function hasCursorCancellation(input: { run: unknown; issue: unknown; events: readonly unknown[]; runId: string; turnId: string; requestedAt: number }): boolean { + const run = rec(input.run), cancellation = rec(rec(run.resultJson).nativeCancellation); + const terminals = input.events.map(rec).map(row => ({ row, event: rec(rec(row.payload).prpEvent) })).filter(({ event }) => ["turn.cancelled", "turn.interrupted"].includes(event.eventType)); + return run.id === input.runId && run.status === "cancelled" && rec(input.issue).status === "in_progress" + && cancellation.dispatchState === "acknowledged" && cancellation.dispatched === true && cancellation.scope === "run" + && terminals.length === 1 && terminals.every(({ row, event }) => row.runId === input.runId && event.runId === input.runId && event.turnId === input.turnId + && event.schema === "paperclip.prp.event.v1" && event.schemaVersion === 1 && row.protocolSchemaVersion === 1 && event.sourceKind === "runner" + && Number.isFinite(input.requestedAt) && Date.parse(event.emittedAt) >= input.requestedAt); +} + +/** Controller-observed sandbox receipts; never substitute host copy-back files. */ +export interface CursorRemoteBinding { + companyId: string; environmentId: string; runId: string; leaseId: string; + sandboxId: string; remoteCwd: string; image: string; +} +export interface CursorRemoteSnapshot { + binding: CursorRemoteBinding; observedAtMs: number; complete: boolean; + workspace: Record; + targets: Record; + watcher: { complete: boolean; targetMutationCount: number; workspaceMutationCount: number }; + processes: { captured: boolean; root: { pid: number; startTicks: string; bootId: string } | null; + journal: Array<{ pid: number; ppid: number; startTicks: string; bootId: string }>; live: number[] }; +} +export function assertCursorRemoteSnapshot(snapshot: CursorRemoteSnapshot, binding: CursorRemoteBinding): void { + if (!snapshot.complete || !snapshot.watcher.complete || (!Number.isSafeInteger(snapshot.observedAtMs) || snapshot.observedAtMs < 0) + || !Object.keys(binding).every(key => snapshot.binding[key as keyof CursorRemoteBinding] === binding[key as keyof CursorRemoteBinding]) + || !isAbsolute(binding.remoteCwd) || posix.normalize(binding.remoteCwd) !== binding.remoteCwd || !/^.+@sha256:[a-f0-9]{64}$/u.test(binding.image) || ![binding.companyId, binding.environmentId, binding.runId, binding.leaseId, binding.sandboxId].every(id)) throw new Error("Cursor remote evidence lacks exact complete lease authority"); + const entries = Object.entries(snapshot.workspace); + if (entries.length > 2048 || entries.some(([name, value]) => !name || name.startsWith("/") || /[\\\u0000-\u001f\u007f]/u.test(name) + || name.replace(/\/$/u, "").split("/").some(part => !part || part === "." || part === "..") + || (value !== "directory" && !/^sha256:[a-f0-9]{64}$/u.test(value)))) throw new Error("Cursor remote workspace evidence is malformed or unbounded"); + if (![snapshot.watcher.targetMutationCount, snapshot.watcher.workspaceMutationCount].every(value => Number.isSafeInteger(value) && value >= 0)) throw new Error("Cursor remote watcher receipt is malformed"); +} +export function hasCursorRemoteRetirement(snapshot: CursorRemoteSnapshot, binding: CursorRemoteBinding): boolean { + try { assertCursorRemoteSnapshot(snapshot, binding); } catch { return false; } + const p = snapshot.processes, root = p.root; + return p.captured && root !== null && Number.isSafeInteger(root.pid) && root.pid > 1 && /^\d+$/u.test(root.startTicks) + && /^[a-f0-9-]{36}$/iu.test(root.bootId) && p.live.length === 0 && p.journal.length > 0 + && p.journal.some(item => item.pid === root.pid && item.startTicks === root.startTicks && item.bootId === root.bootId) + && p.journal.every(item => Number.isSafeInteger(item.pid) && item.pid > 1 && /^\d+$/u.test(item.startTicks) && item.bootId === root.bootId); +} + +export function cursorRemoteDeniedSample(snapshot: CursorRemoteSnapshot, binding: CursorRemoteBinding, relative: string, phase: string, + expectedParent?: { dev: string; ino: string }) { + assertCursorRemoteSnapshot(snapshot, binding); + if (!/^[a-zA-Z0-9_-]+\.txt$/u.test(relative)) throw new Error("Invalid remote denied target"); + const target = snapshot.targets[relative]; + if (!target || !target.complete || !/^\d+$/u.test(target.parent.dev) || !/^\d+$/u.test(target.parent.ino) + || !Number.isSafeInteger(target.mutationCount) || target.mutationCount !== 0 + || (expectedParent && (target.parent.dev !== expectedParent.dev || target.parent.ino !== expectedParent.ino))) throw new Error("Cursor remote denied target observation is incomplete or changed"); + return { phase, path: posix.join(binding.remoteCwd, relative), absent: target.absent === true && target.sha256 === null, + observedAt: snapshot.observedAtMs, parent: target.parent }; +} + +export function hasCursorRemoteWorkspaceUnchanged(snapshot: CursorRemoteSnapshot, baseline: CursorRemoteSnapshot): boolean { + try { assertCursorRemoteSnapshot(snapshot, baseline.binding); assertCursorRemoteSnapshot(baseline, baseline.binding); } catch { return false; } + return snapshot.observedAtMs >= baseline.observedAtMs + && snapshot.watcher.workspaceMutationCount === baseline.watcher.workspaceMutationCount + && JSON.stringify(Object.entries(snapshot.workspace).sort()) === JSON.stringify(Object.entries(baseline.workspace).sort()); +} diff --git a/tests/runner-e2e/cursor-native-flow.test.ts b/tests/runner-e2e/cursor-native-flow.test.ts new file mode 100644 index 00000000000..59da838ae0a --- /dev/null +++ b/tests/runner-e2e/cursor-native-flow.test.ts @@ -0,0 +1,81 @@ +import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { cursorNativeWorkspaceSnapshot, hasCursorAcceptedPlanWait } from "./cursor-native-flow.js"; +import { cursorNativeCaseDesigns, cursorNativePlanArtifactGate, cursorNativeTasks } from "./cursor-native-cases.js"; + +it("keeps native mode/permission choices explicit and artifact export pending", () => { + expect(cursorNativeTasks.map(task => task.id)).toEqual(cursorNativeCaseDesigns.map(design => design.id)); + for (const task of cursorNativeTasks) { + expect(task.flow).toBe("cursor_native"); expect(task.expectedRunCount).toBe(1); expect(task.turnTimeoutMs).toBe(120_000); + } + expect(cursorNativeCaseDesigns.filter(row => row.method !== "session/request_permission").every(row => row.cursorMode === "plan")).toBe(true); + expect(cursorNativeCaseDesigns.find(row => row.method === "session/request_permission")).toMatchObject({ cursorMode: "agent", permissionMode: "approve-reads" }); + expect(cursorNativeTasks.find(task => task.id === "native-write-deny-reconnect")!.expectedTerminalState).toEqual({ issue: "in_progress", run: "cancelled" }); + expect(cursorNativePlanArtifactGate.status).toBe("pending"); + expect(cursorNativePlanArtifactGate.nativePath).toContain(""); +}); +it("independently detects changed or newly created workspace bytes", async () => { + const root = await mkdtemp(join(tmpdir(), "cursor-workspace-proof-")); + try { + await writeFile(join(root, "source.txt"), "before"); const before = await cursorNativeWorkspaceSnapshot(root); + expect(await cursorNativeWorkspaceSnapshot(root)).toEqual(before); + await writeFile(join(root, "source.txt"), "after"); expect(await cursorNativeWorkspaceSnapshot(root)).not.toEqual(before); + await writeFile(join(root, "new.txt"), "effect"); expect(await cursorNativeWorkspaceSnapshot(root)).toHaveProperty("new.txt"); + } finally { await rm(root, { recursive: true, force: true }); } +}); +it("fails closed on symlinks or oversized proof input", async () => { + const root = await mkdtemp(join(tmpdir(), "cursor-workspace-proof-")); + try { + await symlink("/", join(root, "escape")); await expect(cursorNativeWorkspaceSnapshot(root)).rejects.toThrow(/symlink/); + await rm(join(root, "escape")); await writeFile(join(root, "oversized"), Buffer.alloc(4 * 1024 * 1024 + 1)); + await expect(cursorNativeWorkspaceSnapshot(root)).rejects.toThrow(/byte bound/); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +it("awaits owned remote baseline before returning the exact remote denial command", async () => { + const { prepareCursorRemoteAction } = await import("./cursor-native-flow.js"); + const binding = { companyId: "company", environmentId: "environment", runId: "run", leaseId: "lease", sandboxId: "sandbox", remoteCwd: "/home/daytona/paperclip-workspace", image: `runner@sha256:${"a".repeat(64)}` }; + const snapshot = { binding, observedAtMs: 50, complete: true, workspace: {}, targets: { "denied.txt": { absent: true, sha256: null, parent: { dev: "1", ino: "2" }, mutationCount: 0, complete: true } }, watcher: { complete: true, targetMutationCount: 0, workspaceMutationCount: 0 }, processes: { captured: true, root: { pid: 51, startTicks: "3021", bootId: "12345678-1234-1234-1234-123456789abc" }, journal: [], live: [51] } }; + const order: string[] = []; + const fixture = { binding, actionFile: `.paperclip-eval-action-${"a".repeat(36)}.txt`, remoteCwd: binding.remoteCwd, snapshot: async (label: string) => { order.push(label); await Promise.resolve(); order.push("baseline-ready"); return snapshot; }, finish: async () => snapshot, close: async () => {} }; + const prepared = await prepareCursorRemoteAction({ fixture, ...binding, deniedRelative: "denied.txt", prompt: "Perform the actual test" }); + order.push("publish-action"); + expect(order).toEqual(["before-action-publication", "baseline-ready", "publish-action"]); + expect(prepared.prompt).toContain("printf 'MUST_NOT_EXIST' > '/home/daytona/paperclip-workspace/denied.txt'"); + expect(prepared.initial).toMatchObject({ phase: "before-request", absent: true }); + expect(prepared.command?.commandSha256).toMatch(/^sha256:[a-f0-9]{64}$/); + await expect(prepareCursorRemoteAction({ fixture, ...binding, runId: "wrong-run", deniedRelative: "denied.txt", prompt: "test" })).rejects.toThrow(/another run/); + snapshot.targets["denied.txt"].absent = false; + await expect(prepareCursorRemoteAction({ fixture, ...binding, deniedRelative: "denied.txt", prompt: "test" })).rejects.toThrow(/present before/); +}); + +it("refuses remote native execution before touching API when bootstrap or cleanup authority is missing", async () => { + const { runCursorNativeFlow } = await import("./cursor-native-flow.js"); + let apiCalls = 0; + await expect(runCursorNativeFlow({ + execution: { task: { id: "native-question-reconnect" }, profile: { qualificationCandidate: "cursor" }, environment: { id: "daytona" } }, + api: { get: async () => { apiCalls++; throw new Error("must not call"); } }, + } as any)).rejects.toThrow(/owned pre-action observer/); + expect(apiCalls).toBe(0); +}); + +it("requires the exact passive accepted-plan disposition without mode promotion or extra work", () => { + const state = { issue: { id: "issue", status: "in_progress" }, interactions: [{ status: "answered" }], runs: [{ id: "run", nativeIssueId: "issue", runtimeMode: "native", status: "succeeded", runnerProfileJson: { nativeExecutionInput: { provider: { cursorMode: "plan" } } }, resultJson: { finalizationPhase: "committed", finalizationReasonCode: "native_plan_accepted_waiting_for_continuation", authoritativeDecision: "in_progress" } }] }; + expect(hasCursorAcceptedPlanWait(state)).toBe(true); + const mutations = [ + (s: typeof state) => { s.issue.status = "done"; }, + (s: typeof state) => { s.runs[0]!.status = "failed"; }, + (s: typeof state) => { s.runs[0]!.nativeIssueId = "foreign"; }, + (s: typeof state) => { s.runs[0]!.runnerProfileJson.nativeExecutionInput.provider.cursorMode = "agent"; }, + (s: typeof state) => { s.runs[0]!.resultJson.finalizationReasonCode = "live_continuation_registered"; }, + (s: typeof state) => { s.runs[0]!.resultJson.finalizationPhase = "pending"; }, + (s: typeof state) => { s.runs.push(structuredClone(s.runs[0]!)); }, + (s: typeof state) => { s.interactions[0]!.status = "pending"; }, + ]; + for (const mutate of mutations) { const changed = structuredClone(state); mutate(changed); expect(hasCursorAcceptedPlanWait(changed)).toBe(false); } + expect(cursorNativeTasks.find(task => task.id === "native-plan-reject-revise-accept")!.expectedTerminalState).toEqual({ issue: "in_progress", run: "succeeded" }); + const prompt = cursorNativeTasks.find(task => task.id === "native-plan-reject-revise-accept")!.buildPrompt("test"); + expect(prompt).toContain("do not implement, change modes, call paperclip_finish, or start another turn"); +}); diff --git a/tests/runner-e2e/cursor-native-flow.ts b/tests/runner-e2e/cursor-native-flow.ts new file mode 100644 index 00000000000..1d04de5709b --- /dev/null +++ b/tests/runner-e2e/cursor-native-flow.ts @@ -0,0 +1,393 @@ +import { isDeepStrictEqual } from "node:util"; +import { hasAcpxNativeOrigin } from "./acpx-native-origin.js"; +import { createHash, randomBytes } from "node:crypto"; +import { lstat, readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, type Page } from "@playwright/test"; +import { pollUntil, type RunnerApi } from "./api.js"; +import { collectRunEvents } from "./run-observations.js"; +import { createTaskThroughUi } from "./user-actions.js"; +import { observeRunProcesses, createDeniedTargetFixture } from "./native-local-fixtures.js"; +import { cursorDeniedCommand, hasCursorDeniedCommand, hasCursorCancellation, readCursorToolEvidence, assertCursorRemoteSnapshot, cursorRemoteDeniedSample, hasCursorRemoteRetirement, hasCursorRemoteWorkspaceUnchanged, type CursorRemoteSnapshot, type CursorRemoteBinding, type CursorToolNotice } from "./cursor-native-evidence.js"; +import { cursorNativeCaseDesigns, cursorNativePlanArtifactGate, hasCursorDenialBoundary, hasCursorPlanDecision, hasDeliveredCursorNativeRequest, hasExactCursorNativeResponse, type CursorNativeMethod } from "./cursor-native-cases.js"; +import type { LiveFixtureValues } from "./live-fixtures.js"; +import type { MatrixExecution } from "./types.js"; + +type Row = Record; +type Check = { id: string; passed: boolean; detail: string }; + +/** Select only the exact native callback, including either production ACPX bridge. */ +export function findCursorNativeRequest(rows: Row[], method: CursorNativeMethod, requestId?: string): Row | undefined { + return rows.map(row => row.payload?.prpEvent).find(event => event?.eventType === "runtime_request.created" + && hasAcpxNativeOrigin(event.payload?.request?.origin, "cursor", method) + && (!requestId || event.payload.request.requestId === requestId)); +} + +/** JSON object key order may change during persistence; array order and values may not. */ +export function hasCursorNativeCardBinding(card: Row, event: Row, runId: string): boolean { + return card.sourceRunId === runId && card.continuationPolicy === "none" + && isDeepStrictEqual(card.payload.questionSet, event.payload.request.input); +} + +/** This is a successful planning boundary, not an implementation/completion claim. */ +export function hasCursorAcceptedPlanWait(state: { issue: Row; runs: Row[]; interactions: Row[] }): boolean { + if (state.runs.length !== 1 || state.issue.status !== "in_progress" || state.interactions.some(card => card.status === "pending")) return false; + const run = state.runs[0]!; + return run.status === "succeeded" && run.runtimeMode === "native" && run.nativeIssueId === state.issue.id + && run.runnerProfileJson?.nativeExecutionInput?.provider?.cursorMode === "plan" + && run.resultJson?.finalizationPhase === "committed" + && run.resultJson?.finalizationReasonCode === "native_plan_accepted_waiting_for_continuation" + && run.resultJson?.authoritativeDecision === "in_progress"; +} + +export interface CursorRemoteNativeFixture { + binding: CursorRemoteBinding; remoteCwd: string; actionFile: string; + snapshot(label: string): Promise; + finish(): Promise; + close(): Promise; +} +export interface CursorRemoteBootstrap { + prompt(nonce: string): string; + bindAndRelease(input: { issueId: string; runId: string; targets: string[]; + actionPrompt(fixture: CursorRemoteNativeFixture): Promise | string }): Promise; +} + + +/** Runs inside the awaited bootstrap callback, before action publication. */ +export async function prepareCursorRemoteAction(input: { + fixture: CursorRemoteNativeFixture; companyId: string; environmentId: string; runId: string; + prompt: string; deniedRelative?: string; +}) { + const { fixture } = input; + if (fixture.binding.runId !== input.runId || fixture.binding.companyId !== input.companyId || fixture.binding.environmentId !== input.environmentId || fixture.remoteCwd !== fixture.binding.remoteCwd) throw new Error("Cursor bootstrap lease belongs to another run or workspace"); + const baseline = await fixture.snapshot("before-action-publication"); + assertCursorRemoteSnapshot(baseline, fixture.binding); + const initial = input.deniedRelative ? cursorRemoteDeniedSample(baseline, fixture.binding, input.deniedRelative, "before-request") : null; + if (initial && !initial.absent) throw new Error("Remote denied target was present before action publication"); + const command = initial ? cursorDeniedCommand(initial.path) : null; + return { baseline, initial, command, prompt: input.prompt + (command ? `\nExact native shell command (copy verbatim):\n${command.command}` : "") }; +} + +/** Match canonical omission of an unanswered optional feedback field. */ +export function cursorNativePlanResponse(planId: string, decision: "accept" | "reject" | "cancel", feedback: string) { + return { schema: "paperclip.question_response.v1", answers: { [planId]: { selectedOptionIds: [decision] }, ...(decision === "reject" ? { reason: { text: feedback } } : {}) } }; +} + +/** Independent bounded snapshot; symlinks are rejected without following them. */ +export async function cursorNativeWorkspaceSnapshot(root: string): Promise> { + const files: Record = {}; let bytes = 0; + async function scan(relative = "") { + for (const entry of (await readdir(join(root, relative))).sort()) { + const name = relative ? `${relative}/${entry}` : entry; const path = join(root, name); const stat = await lstat(path); + if (Object.keys(files).length >= 2048) throw new Error("Cursor workspace proof exceeds file bound"); + if (stat.isSymbolicLink()) throw new Error("Cursor workspace proof cannot authorize a symlink"); + if (stat.isDirectory()) { files[`${name}/`] = "directory"; await scan(name); } + else if (stat.isFile()) { + bytes += stat.size; + if (stat.size > 4 * 1024 * 1024 || bytes > 32 * 1024 * 1024) throw new Error("Cursor workspace proof exceeds byte bound"); + files[name] = createHash("sha256").update(await readFile(path)).digest("hex"); + } else throw new Error("Cursor workspace proof found a special file"); + } + } + await scan(); return files; +} + +export async function runCursorNativeFlow(input: { + page: Page; api: RunnerApi; fixtures: LiveFixtureValues; execution: MatrixExecution; nonce: string; + workspacePath: string; deadlineAt: number; + observe(issue: Row, runs: Row[]): void; + capture(id: string, label: string, file: string): Promise; + evidence(name: string, data: unknown): Promise; + registerCleanupAssertion?(assertion: () => Promise): void; + registerBeforeEnvironmentTeardownAssertion?(assertion: () => Promise): void; + remoteBootstrap?: CursorRemoteBootstrap; +}) { + const { page, api, fixtures, execution, nonce } = input; + const design = cursorNativeCaseDesigns.find(value => value.id === execution.task.id); + const remote = execution.environment.id === "daytona"; + if (!design || !["local", "daytona"].includes(execution.environment.id) || execution.profile.qualificationCandidate !== "cursor") throw new Error("Cursor native fixtures require an explicit isolated Cursor candidate"); + if (remote && (!input.remoteBootstrap || !input.registerBeforeEnvironmentTeardownAssertion)) throw new Error("Cursor Daytona requires an owned pre-action observer and pre-teardown verification"); + if (!remote && ["native-write-deny-reconnect", "native-plan-reject-revise-accept"].includes(design.id) && !input.registerCleanupAssertion) throw new Error("Cursor denial requires authoritative post-cleanup verification"); + const checks: Check[] = []; let issue: Row = {}; let runs: Row[] = []; + const check = (id: string, passed: boolean, detail: string) => { checks.push({ id, passed, detail }); expect(passed, detail).toBe(true); }; + const events = (runId: string) => collectRunEvents((afterSeq, limit) => api.get(`/api/heartbeat-runs/${runId}/events?afterSeq=${afterSeq}&limit=${limit}`)); + const absent = async (path: string) => { try { await lstat(path); return false; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; throw error; } }; + const agent = await api.get(`/api/agents/${fixtures.agent.id}`); + const configured = await api.patch(`/api/agents/${fixtures.agent.id}`, { adapterConfig: { ...agent.adapterConfig, acpxSessionMode: design.cursorMode, acpxPermissionMode: design.permissionMode, ...(remote || design.id === "native-write-deny-reconnect" ? { lifecycleMode: "per_turn", timeoutSec: 120 } : {}) } }); + check("explicit-mode-policy", configured.adapterConfig.acpxSessionMode === design.cursorMode && configured.adapterConfig.acpxPermissionMode === design.permissionMode, "Public agent configuration selected mode and permission policy separately before provider startup"); + if (remote || design.id === "native-write-deny-reconnect") check("per-turn-process-authority", configured.adapterConfig.lifecycleMode === "per_turn" && configured.adapterConfig.timeoutSec === 120, "Public configuration admits a bounded per-turn provider process before startup"); + await input.evidence("cursor-native-contract.json", { caseId: design.id, mode: design.cursorMode, permissionMode: design.permissionMode, method: design.method, expectedRunCount: 1, nativeCallbackRequired: true, artifactGate: cursorNativePlanArtifactGate }); + const project = await api.post(`/api/companies/${fixtures.company.id}/projects`, { + name: `Cursor native workspace ${nonce}`, executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", sharedWorkspaceConcurrency: "serialize", allowIssueOverride: false, environmentId: fixtures.environment.id, workspaceStrategy: { type: "project_primary" } }, + workspace: { name: "Primary", sourceType: "local_path", cwd: input.workspacePath, isPrimary: true }, + }); + const baseline = remote ? null : await cursorNativeWorkspaceSnapshot(input.workspacePath); + let remoteFixture: CursorRemoteNativeFixture | null = null; let remoteFinal: CursorRemoteSnapshot | null = null; let remoteBaseline: CursorRemoteSnapshot | null = null; + let remoteParent: { dev: string; ino: string } | undefined; + const sampleWorkspace = async (phase: string) => { + if (remote) { + if (!remoteFixture) throw new Error("Cursor remote workspace observer is absent"); + const current = remoteFinal ?? await remoteFixture.snapshot(phase); + await input.evidence(`cursor-workspace-${phase}.json`, { baseline: remoteBaseline, current }); + check(`workspace-unchanged-${phase}`, hasCursorRemoteWorkspaceUnchanged(current, remoteBaseline!), "Owned remote workspace hashes and continuous mutation journal remain unchanged"); + return; + } + const current = await cursorNativeWorkspaceSnapshot(input.workspacePath); + await input.evidence(`cursor-workspace-${phase}.json`, { baseline, current }); + check(`workspace-unchanged-${phase}`, JSON.stringify(current) === JSON.stringify(baseline), "Independent workspace bytes remain unchanged by a pending/rejected/cancelled native plan or question"); + }; + const localDeniedTarget = !remote && design.id === "native-write-deny-reconnect" ? await createDeniedTargetFixture(input.workspacePath, `cursor-denied-${nonce}.txt`) : null; + const deniedRelative = localDeniedTarget?.targetRelativePath ?? `cursor-denied-${nonce}.txt`; + let deniedPath = remote ? "" : join(input.workspacePath, deniedRelative); + let deniedCommand = remote ? null : cursorDeniedCommand(deniedPath); + let denialNotices: CursorToolNotice[] = []; let denialRunEvents: Row[] = []; let denialTurnId = ""; let cancelRequestedAt = NaN; let cancellationProven = false; + const samples: Array<{ phase: string; path: string; absent: boolean; observedAt: number }> = []; + const sampleDenied = async (phase: string, retained?: CursorRemoteSnapshot) => { + let sample: { phase: string; path: string; absent: boolean; observedAt: number }; + if (remote) { + if (!remoteFixture) throw new Error("Cursor remote denied-target observer is absent"); + sample = cursorRemoteDeniedSample(retained ?? remoteFinal ?? await remoteFixture.snapshot(phase), remoteFixture.binding, deniedRelative, phase, remoteParent); + } else sample = { phase, path: deniedPath, absent: await absent(deniedPath), observedAt: Date.now() }; + samples.push(sample); await input.evidence("cursor-denial-samples.json", samples); check(`denied-absent-${phase}`, sample.absent, "Independent denied target remains absent"); + }; + const processObserver = remote ? null : observeRunProcesses(); let processAuthority: string | null = null; let processObservationError = false; + const observeProcesses = () => { + const run = runs[0]; + const authority = run?.processPid ? { pid: run.processPid, groupId: run.processGroupId, startedAt: run.processStartedAt, runId: run.id } : undefined; + if (authority) { + const key = JSON.stringify(authority); + if (processAuthority !== null && processAuthority !== key) processObservationError = true; + processAuthority ??= key; + } + return processObserver ? processObserver.sample(authority) : { captured: false, journal: [], live: [] }; + }; + let processes = observeProcesses(); + let deniedRequest: Row | null = null; + let processTimer: ReturnType | undefined; + const watch = localDeniedTarget?.watcher ?? null; + if (watch) { + processTimer = setInterval(() => { try { processes = observeProcesses(); } catch { processObservationError = true; } }, 250); + input.registerCleanupAssertion!(async () => { + const cleanupChecks: Check[] = []; + const finalCheck = (id: string, passed: boolean, detail: string) => { cleanupChecks.push({ id, passed, detail }); }; + try { + if (issue.id) await load(); + processes = observeProcesses(); + if (processes.captured && processes.live.length > 0 && !processObservationError) { + processes = await pollUntil({ label: "observed Cursor provider retirement", deadlineAt: Date.now() + 5_000, + load: async () => observeProcesses(), accept: observation => observation.live.length === 0 }); + } + const settled = runs.length === 1 && runs[0]!.status === "cancelled" && issue.status === "in_progress" && cancellationProven; + finalCheck("authoritative-provider-cleanup", settled && !processObservationError && processes.captured && processes.live.length === 0, "Exact API-bound per-turn process/start/group and observed descendants have retired"); + const finalSample = { phase: "after-cleanup", path: deniedPath, absent: await absent(deniedPath), observedAt: Date.now() }; + samples.push(finalSample); + finalCheck("denied-absent-after-cleanup", finalSample.absent, "Independent target remains absent after observed provider retirement"); + const journal = watch.finish(); + finalCheck("continuous-denial-observation", journal.complete && journal.targetMutationCount === 0, "Continuous target watcher observed no create/delete mutation and retained directory identity"); + finalCheck("complete-native-denial-boundary", Boolean(deniedRequest) && hasCursorDenialBoundary({ request: deniedRequest, expectedRequestId: deniedRequest?.requestId ?? "", expectedToolCallId: deniedRequest?.details.toolCallId ?? "", path: deniedPath, bootstrapReadProof: remoteFixture ? { actionFile: remoteFixture.actionFile, events: denialRunEvents } : undefined, samples, notices: denialNotices, runId: runs[0]?.id ?? "", turnId: denialTurnId }), "Supported native denial preserved the target through all six independent boundaries"); + await input.evidence("cursor-native-denial-final.json", { request: deniedRequest, samples, notices: denialNotices, commandSha256: deniedCommand!.commandSha256, cancellationProven, cancelRequestedAt, processes, processObservationError, watcher: journal, checks: cleanupChecks }); + if (cleanupChecks.some(row => !row.passed)) throw new Error("Cursor native denial cleanup proof is incomplete or observed an effect"); + return cleanupChecks; + } finally { + clearInterval(processTimer); + await input.evidence("cursor-native-denial-cleanup-attempt.json", { request: deniedRequest, samples, notices: denialNotices, commandSha256: deniedCommand!.commandSha256, cancellationProven, cancelRequestedAt, processes, processObservationError, watcher: watch.finish(), checks: cleanupChecks }); + } + }); + } + if (remote) input.registerBeforeEnvironmentTeardownAssertion!(async () => { + const cleanupChecks: Check[] = []; let snapshot: CursorRemoteSnapshot | null = null; + const finalCheck = (id: string, passed: boolean, detail: string) => { cleanupChecks.push({ id, passed, detail }); }; + try { + if (!remoteFixture) throw new Error("Cursor remote observer never acquired authoritative baseline"); + if (issue.id) await load(); + snapshot = remoteFinal ?? await remoteFixture.finish(); remoteFinal = snapshot; + finalCheck("remote-provider-retired", runs.length === 1 && remoteBaseline !== null && snapshot.observedAtMs >= remoteBaseline.observedAtMs && hasCursorRemoteRetirement(snapshot, remoteFixture.binding), "Exact remote run PID/start/boot identity and all observed descendants retired before the retained receipt sealed"); + if (design.id === "native-write-deny-reconnect") { + await sampleDenied("after-cleanup", snapshot); + finalCheck("remote-cancel-terminal", cancellationProven && runs[0]?.status === "cancelled" && issue.status === "in_progress", "Explicit native cancellation remains durable without false task completion"); + finalCheck("remote-no-denied-effect", snapshot.watcher.targetMutationCount === 0 && Boolean(deniedRequest) && hasCursorDenialBoundary({ request: deniedRequest, expectedRequestId: deniedRequest?.requestId ?? "", expectedToolCallId: deniedRequest?.details.toolCallId ?? "", path: deniedPath, bootstrapReadProof: remoteFixture ? { actionFile: remoteFixture.actionFile, events: denialRunEvents } : undefined, samples, notices: denialNotices, runId: runs[0]?.id ?? "", turnId: denialTurnId }), "Exact denied native command caused no remote file effect through provider retirement"); + } else { + if (design.id === "native-plan-reject-revise-accept") finalCheck("remote-plan-still-passive", hasCursorAcceptedPlanWait(await load()), "Accepted planning remains passive through remote retirement without an automatic follow-up run"); + finalCheck("remote-no-workspace-effects", hasCursorRemoteWorkspaceUnchanged(snapshot, remoteBaseline!), "Native question or cancelled plan caused no remote workspace mutation through retirement"); + } + if (cleanupChecks.some(row => !row.passed)) throw new Error("Cursor remote cleanup evidence is incomplete or observed an effect"); + return cleanupChecks; + } finally { + try { await input.evidence("cursor-remote-cleanup.json", { snapshot, samples, notices: denialNotices, cancellationProven, checks: cleanupChecks }); } + finally { await remoteFixture?.close(); } + } + }); + const load = async () => { + issue = await api.get(`/api/issues/${issue.id}`); + const listed = await api.get(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`); + runs = await Promise.all(listed.map(run => api.get(`/api/heartbeat-runs/${run.id}`))); + runs.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))); input.observe(issue, runs); + if (watch) processes = observeProcesses(); + const interactions = await api.get(`/api/issues/${issue.id}/interactions`); + const runEvents = runs.length === 1 ? await events(runs[0]!.id) : []; + if (design.id === "native-write-deny-reconnect" && runs.length === 1) { denialRunEvents = runEvents; denialNotices = readCursorToolEvidence(runEvents, runs[0]!.id); } + return { issue, runs, interactions, runEvents }; + }; + if (!remote && design.id === "native-plan-reject-revise-accept") input.registerCleanupAssertion!(async () => { + const current = await load(); + const currentWorkspace = await cursorNativeWorkspaceSnapshot(input.workspacePath); + const cleanupChecks = [ + { id: "plan-still-passive-after-cleanup", passed: hasCursorAcceptedPlanWait(current), detail: "One succeeded planning run remains unfinished without automatic follow-up through fixture cleanup" }, + { id: "plan-workspace-unchanged-after-cleanup", passed: isDeepStrictEqual(currentWorkspace, baseline), detail: "Accepted planning caused no workspace effect through fixture cleanup" }, + ]; + await input.evidence("cursor-native-plan-wait-cleanup.json", { ...current, baseline, currentWorkspace, checks: cleanupChecks }); + if (cleanupChecks.some(row => !row.passed)) throw new Error("Accepted Cursor plan did not remain a passive no-effect boundary"); + return cleanupChecks; + }); + const reject = (state: Awaited>) => state.runs.length > 1 ? "Unexpected extra Cursor provider run" : state.runs.some(run => ["failed", "cancelled", "timed_out"].includes(run.status)) ? "Cursor provider run failed" : undefined; + async function pending(seen: Set) { + const state = await pollUntil({ label: "exact native Cursor callback", deadlineAt: input.deadlineAt, load, + reject: state => reject(state) ?? (state.runs.some(run => run.status === "succeeded") ? "Native Cursor callback was not observed before completion; qualification remains pending" : undefined), + accept: state => state.interactions.some(card => card.status === "pending" && !seen.has(card.id) && card.payload?.runtimeRequestId && findCursorNativeRequest(state.runEvents, design!.method, card.payload.runtimeRequestId)) }); + const cards = state.interactions.filter(card => card.status === "pending"); check("single-native-request", cards.length === 1 && state.runs.length === 1, "Exactly one native request belongs to one original provider run"); + const card = cards[0]!; const event = findCursorNativeRequest(state.runEvents, design!.method, card.payload.runtimeRequestId)!; + check("native-card-binding", hasCursorNativeCardBinding(card, event, state.runs[0]!.id), "Durable card retains complete native input and exact source run"); + await input.evidence(`cursor-native-${seen.size}-pending.json`, { card, event }); + await page.reload(); const reloaded = (await load()).interactions.find(row => row.id === card.id); + check("browser-reconnect-identity", reloaded?.status === "pending" && reloaded?.payload.runtimeRequestId === card.payload.runtimeRequestId, "Browser reconnect preserves exact outstanding native request"); + await input.capture(`cursor-native-${seen.size}`, "Native Cursor request pending", `cursor-native-${seen.size}.png`); + return { card, event }; + } + async function delivery(card: Row, event: Row, response: Row) { + const identity = { runId: runs[0]!.id, turnId: event.turnId, requestId: card.payload.runtimeRequestId, method: design!.method, action: "submit" as const, response }; + const state = await pollUntil({ label: "native response stream delivery", deadlineAt: input.deadlineAt, load, reject, + accept: state => state.interactions.some(row => row.id === card.id && row.status === "answered") && hasExactCursorNativeResponse({ ...identity, events: state.runEvents }) }); + check("exact-native-delivery", hasExactCursorNativeResponse({ ...identity, events: state.runEvents }), "Exact displayed answer reached the native response writer and produced one ordered delivery receipt"); + await input.evidence(`cursor-native-${card.id}-delivered.json`, { interaction: state.interactions.find(row => row.id === card.id), events: state.runEvents, response }); + } + let expectedMarker = execution.task.buildVisibleMarker(nonce); + try { + if (!remote && design.id === "native-write-deny-reconnect") await sampleDenied("before-request"); + await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : execution.task.buildPrompt(nonce) + (watch ? `\nExact native shell command (copy verbatim):\n${deniedCommand!.command}` : ""), workMode: "standard", projectName: project.name }); + issue = await pollUntil({ label: "browser-created Cursor task", deadlineAt: input.deadlineAt, load: async () => (await api.get(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.title === execution.task.buildTitle(nonce)), accept: Boolean }) ?? {}; + if (!issue.id) throw new Error("Browser-created Cursor task is absent"); + if (remote) { + const started = await pollUntil({ label: "exact Cursor bootstrap run", deadlineAt: input.deadlineAt, load, reject, + accept: state => state.runs.length === 1 }); + const runId = started.runs[0]!.id; + const boundFixture = await input.remoteBootstrap!.bindAndRelease({ issueId: issue.id, runId, + targets: design.id === "native-write-deny-reconnect" ? [deniedRelative] : [], + actionPrompt: async fixture => { + if (remoteFixture) throw new Error("Cursor remote action was published more than once"); + remoteFixture = fixture; + const prepared = await prepareCursorRemoteAction({ fixture, runId, companyId: fixtures.company.id, environmentId: fixtures.environment.id, + prompt: execution.task.buildPrompt(nonce), ...(design.id === "native-write-deny-reconnect" ? { deniedRelative } : {}) }); + remoteBaseline = prepared.baseline; + if (prepared.initial && prepared.command) { + deniedPath = prepared.initial.path; deniedCommand = prepared.command; remoteParent = prepared.initial.parent; samples.push(prepared.initial); + } + return prepared.prompt; + } }); + if (!remoteFixture || boundFixture !== remoteFixture || boundFixture.binding.runId !== runId) throw new Error("Cursor remote observer is missing its run binding"); + await input.evidence("cursor-remote-baseline.json", remoteBaseline); + } + await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`); + const seen = new Set(); + if (design.id === "native-question-reconnect") { + const { card, event } = await pending(seen); const questions = card.payload.questionSet.questions; + if (remote) await sampleWorkspace("question-pending"); + check("native-question-shape", questions.length === 2 && questions[0].answerMode === "single_select" && questions[1].answerMode === "multi_select", "Native single/multiple choice shape is preserved"); + const color = randomBytes(1)[0]! % 2 === 0 ? "Cobalt" : "Amber"; + const trees = randomBytes(1)[0]! % 2 === 0 ? ["Cedar", "Maple"] : ["Maple"]; + await page.getByRole("radio", { name: color, exact: true }).last().click(); + await page.getByRole("button", { name: "Next", exact: true }).last().click(); + for (const tree of trees) await page.getByRole("checkbox", { name: tree, exact: true }).last().click(); + const answers = Object.fromEntries(questions.map((question: Row, index: number) => [question.id, { selectedOptionIds: (index === 0 ? [color] : trees).map(label => question.options.find((option: Row) => option.label === label)?.id) }])); + await page.getByRole("button", { name: card.payload.questionSet.submitLabel ?? "Submit answers", exact: true }).last().click(); + await delivery(card, event, { schema: "paperclip.question_response.v1", answers }); + expectedMarker = `CURSOR-NATIVE-${nonce}-${color.toLowerCase()}-${trees.map(tree => tree.toLowerCase()).sort().join("+")}`; + await sampleWorkspace("question-delivered"); + } else if (design.method === "cursor/create_plan") { + const decisions = design.id === "native-plan-cancel" ? ["cancel"] as const : ["reject", "accept"] as const; + const feedbackMarker = `revision-${randomBytes(12).toString("hex")}`; + const feedback = `Include verification marker ${feedbackMarker} in the revised plan.`; let previousRevision: string | null = null; + for (const decision of decisions) { + const { card, event } = await pending(seen); const set = card.payload.questionSet; const plan = set.questions.find((question: Row) => /^plan-[a-f0-9]{64}$/.test(question.id)); + check("full-plan-revision", typeof set.description === "string" && set.description.length > 0 && Boolean(plan) && plan.id !== previousRevision, "Complete native plan and a distinct content-bound revision are retained"); + if (decision === "accept") check("revision-feedback", set.description.includes(feedbackMarker), "Revised native plan contains exact undisclosed rejection feedback"); + for (const marker of [`CURSOR-PLAN-BEGIN-${nonce}`, `CURSOR-PLAN-END-${nonce}`]) { + check("complete-plan-boundary", set.description.includes(marker), "Retained native plan includes its full document boundaries"); + await expect(page.getByRole("region", { name: "Question context" }).last()).toContainText(marker); + } + await sampleWorkspace(`plan-${decision}-pending`); + await page.getByRole("radio", { name: decision === "accept" ? "Accept plan" : decision === "reject" ? "Reject plan" : "Cancel plan request", exact: true }).last().click(); + await page.getByRole("button", { name: "Next", exact: true }).last().click(); + if (decision === "reject") await page.getByTestId("question-text-answer-composer").last().locator('[contenteditable="true"],textarea').first().fill(feedback); + const response = cursorNativePlanResponse(plan.id, decision, feedback); + check("revision-bound-decision", hasCursorPlanDecision(set, response, decision), "Decision addresses precisely the displayed native plan revision"); + await page.getByRole("button", { name: set.submitLabel ?? "Submit answers", exact: true }).last().click(); + await delivery(card, event, response); seen.add(card.id); previousRevision = plan.id; + if (decision !== "accept") await sampleWorkspace(`plan-${decision}-delivered`); + } + await input.evidence("cursor-native-artifact-gap.json", cursorNativePlanArtifactGate); + } else { + const state = await pollUntil({ label: "native Cursor permission with exact command provenance", deadlineAt: input.deadlineAt, load, reject, + accept: state => denialNotices.some(notice => notice.stage === "permission_requested" && notice.commandSha256 === deniedCommand!.commandSha256 + && Boolean(findCursorNativeRequest(state.runEvents, "session/request_permission", notice.requestId))) }); + const native = denialNotices.find(notice => notice.stage === "permission_requested" && notice.commandSha256 === deniedCommand!.commandSha256)!; + const event = findCursorNativeRequest(state.runEvents, "session/request_permission", native.requestId)!; const request = event.payload.request; deniedRequest = request; denialTurnId = event.turnId; + check("native-permission-identity", state.runs.length === 1 && request.details?.toolCallId === native.toolCallId && native.turnId === event.turnId && native.declineOffered && request.choices.some((choice: Row) => choice.key === "decline"), "Presented native permission is bound to the exact absolute-target command and supported denial choice"); + await sampleDenied("pending"); await page.reload(); + const reloaded = await load(); check("permission-reconnect", Boolean(findCursorNativeRequest(reloaded.runEvents, "session/request_permission", request.requestId)) && !reloaded.runEvents.some(row => row.payload?.prpEvent?.eventType === "runtime_request.resolved" && row.payload.prpEvent.payload?.requestId === request.requestId), "Reconnect preserves the unresolved native permission"); + await sampleDenied("browser-reconnected"); await input.capture("cursor-permission", "Native write permission awaiting denial", "cursor-permission.png"); + const card = page.getByTestId("task-chat-runtime-request").filter({ visible: true }); await expect(card).toHaveCount(1); + const label = request.choices.find((choice: Row) => choice.key === "decline").label; + const route = `/api/heartbeat-runs/${native.runId}/runtime-requests/${encodeURIComponent(request.requestId)}/resolve`; + const sent = page.waitForRequest(row => new URL(row.url()).pathname === route && row.method() === "POST"); + await card.getByRole("button", { name: label, exact: true }).click(); const posted = (await sent).postDataJSON(); + check("browser-exact-denial", posted.turnId === event.turnId && posted.requestKind === "permission_approval" && posted.resolution?.action === "decline", "Browser denied the exact native run/request/turn"); + const identity = { runId: native.runId, turnId: event.turnId, requestId: request.requestId, method: "session/request_permission" as const, action: "decline" as const }; + await pollUntil({ label: "native denial delivered and exact command failed", deadlineAt: input.deadlineAt, load, reject, accept: state => hasDeliveredCursorNativeRequest({ ...identity, events: state.runEvents }) + && hasCursorDeniedCommand({ notices: denialNotices, ...identity, toolCallId: native.toolCallId, commandSha256: deniedCommand!.commandSha256, bootstrapReadProof: remoteFixture ? { actionFile: remoteFixture.actionFile, events: denialRunEvents } : undefined }) }); + await sampleDenied("after-decision"); + cancelRequestedAt = Date.now(); await api.post(`/api/heartbeat-runs/${native.runId}/cancel`); + const cancelled = await pollUntil({ label: "explicit native cancellation", deadlineAt: input.deadlineAt, load, + reject: state => state.runs.length !== 1 || ["failed", "succeeded", "timed_out"].includes(state.runs[0]?.status) ? "Cursor denial did not remain cancellable" : undefined, + accept: state => hasCursorCancellation({ run: state.runs[0], issue: state.issue, events: state.runEvents, runId: native.runId, turnId: event.turnId, requestedAt: cancelRequestedAt }) }); + cancellationProven = true; + if (remote) remoteFinal = await remoteFixture!.finish(); + await sampleDenied("after-terminal", remoteFinal ?? undefined); + check("negative-task-unfinished", cancelled.issue.status === "in_progress" && cancelled.runs[0]?.status === "cancelled" && cancelled.runs[0]?.runtimeMode === "native", "Native cancellation was acknowledged and the task does not falsely claim completion"); + await page.reload(); + await expect(page.getByTestId("issue-detail-header").getByRole("button", { name: "Change status (current: In Progress)", exact: true })).toBeVisible(); + const comments = await api.get(`/api/issues/${issue.id}/comments`); + await input.evidence("api-state.json", { ...cancelled, run: runs[0], comments, checks, notices: denialNotices, commandSha256: deniedCommand!.commandSha256, cancelRequestedAt, runEventsByRun: [{ runId: native.runId, events: cancelled.runEvents }] }); + await input.capture("final-state", "Cursor denied command cancelled; task remains unfinished", "final-state.png"); + return { issue, runs, checks }; + } + if (design.id === "native-plan-reject-revise-accept") { + await pollUntil({ label: "accepted Cursor plan waiting for explicit continuation", deadlineAt: input.deadlineAt, load, reject, accept: hasCursorAcceptedPlanWait }); + const observedAt = Date.now(); + const final = await pollUntil({ label: "passive Cursor plan stability", deadlineAt: input.deadlineAt, load, + reject: state => hasCursorAcceptedPlanWait(state) ? undefined : "Accepted plan started follow-up work or lost its passive disposition", + accept: state => hasCursorAcceptedPlanWait(state) && Date.now() - observedAt >= 2_000, intervalMs: 250 }); + if (remote) remoteFinal = await remoteFixture!.finish(); + await sampleWorkspace("accepted-plan-terminal"); + check("accepted-plan-passive-terminal", hasCursorAcceptedPlanWait(final), "Successful planning remains in progress with exact controller wait reason and selected Plan mode"); + const comments = await api.get(`/api/issues/${issue.id}/comments`); + const summary = "Plan accepted. This task is waiting for your next message. This run used Plan mode; no implementation or task completion is claimed."; + check("explicit-plan-next-action", comments.some(comment => comment.createdByRunId === runs[0]!.id && comment.body === summary), "The original planning run durably presents explicit user continuation"); + await page.reload(); await expect(page.getByText(summary, { exact: true }).last()).toBeVisible(); + await expect(page.getByTestId("issue-detail-header").getByRole("button", { name: "Change status (current: In Progress)", exact: true })).toBeVisible(); + await input.evidence("api-state.json", { ...final, run: runs[0], comments, checks, passiveObservedFrom: observedAt, passiveObservedUntil: Date.now(), runEventsByRun: [{ runId: runs[0]!.id, events: final.runEvents }] }); + await input.capture("final-state", "Accepted Cursor plan waiting for the next user message", "final-state.png"); + return { issue, runs, checks }; + } + const final = await pollUntil({ label: "Cursor native completion", deadlineAt: input.deadlineAt, load, reject, + accept: state => state.issue.status === "done" && state.runs.length === 1 && state.runs[0]!.status === "succeeded" && !state.interactions.some(card => card.status === "pending") }); + if (remote) remoteFinal = await remoteFixture!.finish(); + check("one-native-run", final.runs[0]!.runtimeMode === "native", "Decision and completion remained in the original native provider run"); + if (design.id === "native-plan-cancel" || design.id === "native-question-reconnect") await sampleWorkspace("native-terminal"); + await page.reload(); await expect(page.getByText(expectedMarker, { exact: true }).last()).toBeVisible(); + await expect(page.getByTestId("issue-detail-header").getByRole("button", { name: "Change status (current: Done)", exact: true })).toBeVisible(); + const comments = await api.get(`/api/issues/${issue.id}/comments`); + await input.evidence("api-state.json", { ...final, run: runs[0], comments, checks, runEventsByRun: [{ runId: runs[0]!.id, events: final.runEvents }] }); + await input.capture("final-state", "Cursor native callback fixture verified", "final-state.png"); + return { issue, runs, checks }; + } finally { await input.evidence("cursor-native-checks.json", { issue, runs, checks }); } +} diff --git a/tests/runner-e2e/daytona-image-content.ts b/tests/runner-e2e/daytona-image-content.ts index 92ffbe1b70c..2c79b6d52a1 100644 --- a/tests/runner-e2e/daytona-image-content.ts +++ b/tests/runner-e2e/daytona-image-content.ts @@ -29,16 +29,27 @@ export const DAYTONA_IMAGE_INPUT_PATHS = [ "packages/paperclip-eval-kernel/tsconfig.json", "packages/paperclip-runner/scripts/provision-grok.mjs", "packages/paperclip-runner/package.json", + "packages/paperclip-runner/cursor-distributions.json", + "packages/paperclip-runner/cursor-contract.json", "packages/paperclip-runner/protocol", "packages/paperclip-runner/runner/Cargo.lock", "packages/paperclip-runner/runner/Cargo.toml", "packages/paperclip-runner/runner/crates", "packages/paperclip-runner/scripts/acpx-sidecar-contract.mjs", "packages/paperclip-runner/scripts/build-provider-pack.mjs", + "packages/paperclip-runner/scripts/build-node-startup-timeout.mjs", "packages/paperclip-runner/scripts/candidate-provider-pack.mjs", + "packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs", + "packages/paperclip-runner/scripts/cursor-runtime-patch.mjs", + "packages/paperclip-runner/scripts/cursor-native-usage.mjs", + "packages/paperclip-runner/scripts/cursor-native-usage-source.json", + "packages/paperclip-runner/scripts/provision-cursor.mjs", + "packages/paperclip-runner/cursor-distributions.json", + "packages/paperclip-runner/cursor-contract.json", "packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs", "packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs", "packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs", + // Pi runtime/extension/Node/closure pins are included by the src tree below. "packages/paperclip-runner/src", "packages/paperclip-runner/styles.css", "packages/paperclip-runner/tsconfig.json", diff --git a/tests/runner-e2e/daytona-image.test.ts b/tests/runner-e2e/daytona-image.test.ts index 3d4a2786b5b..25696be09f0 100644 --- a/tests/runner-e2e/daytona-image.test.ts +++ b/tests/runner-e2e/daytona-image.test.ts @@ -200,6 +200,9 @@ describe("runner E2E Daytona image contract", () => { "packages/paperclip-eval-kernel/src", "packages/paperclip-runner/package.json", "packages/paperclip-runner/scripts/candidate-provider-pack.mjs", + "packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs", + "packages/paperclip-runner/scripts/cursor-runtime-patch.mjs", + "packages/paperclip-runner/cursor-distributions.json", "packages/paperclip-runner/runner/crates", "packages/paperclip-runner/src", ]) { @@ -275,6 +278,8 @@ describe("runner E2E Daytona image contract", () => { ), 'pub const VERSION: &str = "one";\n', ); + for (const relativePath of [ + ]) await writeFile(path.join(root, relativePath), "version one\n"); const baseline = await computeDaytonaImageContentId(options); const candidate = await computeDaytonaImageContentId({ ...options, candidateProviders: ["pi"] }); expect(candidate).not.toBe(baseline); @@ -329,6 +334,28 @@ describe("runner E2E Daytona image contract", () => { } }); + it("invalidates the image when the Cursor runtime isolation patch changes", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paperclip-cursor-image-id-")); + const patchPath = "packages/paperclip-runner/scripts/cursor-runtime-patch.mjs"; + expect(DAYTONA_IMAGE_INPUT_PATHS).toContain(patchPath); + const options = { + repositoryRoot: root, + inputPaths: [patchPath], + baseImages: [`example.test/base:1@sha256:${"a".repeat(64)}`], + frontendDigest: `sha256:${"c".repeat(64)}`, + candidateProviders: ["cursor"], + } as const; + try { + await mkdir(path.dirname(path.join(root, patchPath)), { recursive: true }); + await writeFile(path.join(root, patchPath), "export const policy = 'one';\n"); + const baseline = await computeDaytonaImageContentId(options); + await writeFile(path.join(root, patchPath), "export const policy = 'two';\n"); + expect(await computeDaytonaImageContentId(options)).not.toBe(baseline); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("reuses the image for runner-only tests and documentation", async () => { const root = await mkdtemp( path.join(tmpdir(), "paperclip-daytona-runner-development-inputs-"), diff --git a/tests/runner-e2e/failure-classifier.ts b/tests/runner-e2e/failure-classifier.ts index d3671a79071..1f8a82cc7e8 100644 --- a/tests/runner-e2e/failure-classifier.ts +++ b/tests/runner-e2e/failure-classifier.ts @@ -1,4 +1,4 @@ -import { ObservedStateTimeout } from "./api.js"; +import { ObservedStateTimeout, RemoteAdmissionReadError } from "./api.js"; import type { FailureClass } from "./types.js"; const TRANSIENT = @@ -21,7 +21,7 @@ const SANDBOX_TRANSFER_TIMEOUT = /RPC call "environmentSync(?:In|Out)" timed out after \d+ms/i; export function classifyFailure(error: unknown): FailureClass { - if (error instanceof ObservedStateTimeout) return error.failureClass; + if (error instanceof ObservedStateTimeout || error instanceof RemoteAdmissionReadError) return error.failureClass; const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error); if (/browser bootstrap failed before task creation/i.test(message)) diff --git a/tests/runner-e2e/harness-env.ts b/tests/runner-e2e/harness-env.ts index 33d168def17..94d3873d69b 100644 --- a/tests/runner-e2e/harness-env.ts +++ b/tests/runner-e2e/harness-env.ts @@ -115,8 +115,12 @@ export function buildRunnerE2EProcessEnvironment( for (const execution of executions) { const agent = execution.profile.qualificationCandidate; if (!agent) continue; - if (execution.suite.id !== "extended-harnesses" || !execution.suite.manualOnly) { - throw new Error("Candidate qualification requires the explicit extended-harnesses suite"); + const admittedSuite = execution.suite.id === "extended-harnesses" + || execution.suite.id === "rich-acp-warm-continuity" + || (execution.suite.id === "cursor-native" && agent === "cursor") + || (execution.suite.id === "native-active-stop" && agent === "cursor"); + if (!admittedSuite || !execution.suite.manualOnly) { + throw new Error("Candidate qualification requires an explicit provider qualification suite"); } const prior = candidates.get(agent); if (prior !== undefined && prior !== execution.profile.model) throw new Error("Conflicting candidate models"); diff --git a/tests/runner-e2e/launch.ts b/tests/runner-e2e/launch.ts index 2e28e2a975f..5d1ea5680c0 100644 --- a/tests/runner-e2e/launch.ts +++ b/tests/runner-e2e/launch.ts @@ -1,3 +1,4 @@ +import { createProcessTreeOwner, stopOwnedProcessTree } from "./process-tree-owner.js"; import { randomBytes } from "node:crypto"; import { prepareCodexCiSandbox, requiresCodexCiSandbox } from "./codex-ci-sandbox.js"; import { spawn } from "node:child_process"; @@ -50,7 +51,7 @@ import { type MatrixExecution, type RunnerE2EResult, } from "./types.js"; -import { assertRunnerE2EPrerequisites } from "./prerequisites.js"; +import { assertRemoteNativeEvidencePrerequisites, assertRunnerE2EPrerequisites } from "./prerequisites.js"; import { assertNativeCompletionSelection, prepareNativeCompletionPreflight, NATIVE_COMPLETION_PREFLIGHT_ENV } from "./native-completion-admission.js"; import { prepareStockHarnessPreflight, STOCK_PREFLIGHT_ENV } from "./stock-harness-admission.js"; @@ -65,11 +66,8 @@ import { } from "./result-exit-guard.js"; import { observeDescendantProcessTree, - refreshContinuouslyLiveProcessGroups, - revalidateObservedProcessGroups, - safeProcessGroupTerminationOrder, type ObservedProcessGroup, - type ProcessObservation, + readProcessTable, } from "./process-tree.js"; const repositoryRoot = path.resolve(import.meta.dirname, "../.."); @@ -79,19 +77,6 @@ const activeProcessGroups = new Set(); const activeProcessCleanup = new Map>(); const activeProcessTerminators = new Map void>(); const completedResultExitGraceMs = 120_000; -const diagnosticProcessKinds = new Set([ - "bash", - "chrome", - "codex", - "google-chrome", - "node", - "paperclip-runnerd", - "playwright", - "pnpm", - "postgres", - "sh", - "tsx", -]); let cancelled = false; function cleanId(value: string) { @@ -165,72 +150,6 @@ interface ProcessTreeDiagnostic { groups: ObservedProcessGroup[]; } -function observedGroupsSelectedForTermination( - groups: readonly ObservedProcessGroup[], - processGroupIds: readonly number[], -) { - const selected = new Set(processGroupIds); - return groups.filter((group) => selected.has(group.processGroupId)); -} - -async function readProcessTable(): Promise { - if (process.platform === "win32") { - return null; - } - return await new Promise((resolve) => { - const inspector = spawn( - "ps", - ["-e", "-o", "pid=,ppid=,pgid=,lstart=,comm="], - { - env: { PATH: "/usr/bin:/bin", LANG: "C", LC_ALL: "C" }, - stdio: ["ignore", "pipe", "ignore"], - }, - ); - let output = ""; - let settled = false; - const finish = (value: ProcessObservation[] | null) => { - if (settled) return; - settled = true; - clearTimeout(inspectionTimeout); - resolve(value); - }; - const inspectionTimeout = setTimeout(() => { - inspector.kill("SIGKILL"); - finish(null); - }, 5_000); - inspectionTimeout.unref(); - inspector.stdout?.setEncoding("utf8").on("data", (chunk: string) => { - output = `${output}${chunk}`.slice(-1024 * 1024); - }); - inspector.once("error", () => finish(null)); - inspector.once("close", () => { - const observations = output - .split(/\r?\n/) - .map((line) => - /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+?)\s*$/.exec( - line, - ), - ) - .filter((match): match is RegExpExecArray => match !== null) - .map((match): ProcessObservation => { - // A target process can choose its own argv and process name. Emit a - // fixed category instead of target-controlled text so diagnostics - // can never turn that metadata into a secret-exfiltration channel. - const command = path.basename(match[5]!); - const kind = diagnosticProcessKinds.has(command) ? command : "other"; - return { - pid: Number(match[1]), - parentPid: Number(match[2]), - processGroupId: Number(match[3]), - started: match[4]!, - kind, - }; - }); - finish(observations); - }); - }).catch(() => null); -} - async function processTreeDiagnostic( rootPid: number, ): Promise { @@ -357,6 +276,7 @@ async function runProcess( }); if (!child.pid) throw new Error("Failed to start Playwright"); activeProcessGroups.add(child.pid); + const processOwner = createProcessTreeOwner(child); let outputTail = ""; const recordOutput = (chunk: Buffer, destination: NodeJS.WriteStream) => { destination.write(chunk); @@ -374,90 +294,14 @@ async function runProcess( let childSettled = false; let postResultStallError: string | null = null; let boundedCleanup: Promise | undefined; - const forceStopDirectChild = () => { - if (!childSettled && child.exitCode === null && child.signalCode === null) { - child.kill("SIGKILL"); - } - }; - const stopChildTree = (diagnostic?: ProcessTreeDiagnostic) => { + let cleanupSettled!: () => void; + const cleanupFinished = new Promise(resolve => { cleanupSettled = () => resolve(1); }); + const stopChildTree = (_diagnostic?: ProcessTreeDiagnostic) => { if (boundedCleanup) return; - const rootProcessGroupId = child.pid!; - boundedCleanup = (async () => { - const snapshot = diagnostic ?? (await processTreeDiagnostic(child.pid!)); - const validationTable = await readProcessTable(); - const currentProcessGroupId = validationTable - ? (validationTable.find((candidate) => candidate.pid === process.pid) - ?.processGroupId ?? null) - : null; - const observedGroups = validationTable - ? revalidateObservedProcessGroups(snapshot.groups, validationTable) - : []; - const terminationOrder = safeProcessGroupTerminationOrder({ - rootProcessGroupId, - currentProcessGroupId, - groups: observedGroups, - }); - const verifiedGroups = observedGroupsSelectedForTermination( - observedGroups, - terminationOrder, - ); - if (verifiedGroups.length === 0) { - forceStopDirectChild(); - return "Could not revalidate an owned process group before cleanup"; - } - for (const processGroupId of terminationOrder) { - stopProcessGroup(processGroupId, "SIGTERM"); - } - - let remainingGroups = verifiedGroups; - const gracefulDeadline = Date.now() + 5_000; - while (remainingGroups.length > 0 && Date.now() < gracefulDeadline) { - const table = await readProcessTable(); - if (table) { - remainingGroups = refreshContinuouslyLiveProcessGroups( - remainingGroups, - table, - ); - } - if (remainingGroups.length > 0) await wait(50); - } - const remainingGroupIds = new Set( - remainingGroups.map((group) => group.processGroupId), - ); - const forcedOrder = safeProcessGroupTerminationOrder({ - rootProcessGroupId, - currentProcessGroupId, - groups: remainingGroups, - }).filter((processGroupId) => remainingGroupIds.has(processGroupId)); - for (const processGroupId of forcedOrder) { - stopProcessGroup(processGroupId, "SIGKILL"); - } - - const forcedDeadline = Date.now() + 5_000; - let forcedVerificationUnavailable = false; - while (Date.now() < forcedDeadline) { - const table = await readProcessTable(); - if (!table) { - forcedVerificationUnavailable = true; - await wait(50); - continue; - } - forcedVerificationUnavailable = false; - remainingGroups = refreshContinuouslyLiveProcessGroups( - remainingGroups, - table, - ); - if (remainingGroups.length === 0) return null; - await wait(50); - } - if (forcedVerificationUnavailable) { - return "Could not verify descendant process exit after SIGKILL"; - } - return `Verified descendant process groups ${remainingGroups - .map((group) => group.processGroupId) - .join(",")} survived SIGKILL`; - })(); - activeProcessCleanup.set(rootProcessGroupId, boundedCleanup); + boundedCleanup = stopOwnedProcessTree(child, processOwner) + .then(() => null, error => error instanceof Error ? error.message : String(error)) + .finally(cleanupSettled); + activeProcessCleanup.set(child.pid!, boundedCleanup); }; activeProcessTerminators.set(child.pid, stopChildTree); const resultExitGuard = createResultExitGuard({ @@ -508,7 +352,7 @@ async function runProcess( }, timeoutMs); timer?.unref(); let spawnError: string | null = null; - const exitCode = await new Promise((resolve, reject) => { + const childExit = new Promise((resolve, reject) => { child.once("error", reject); child.once("exit", (code) => { childSettled = true; @@ -519,13 +363,21 @@ async function runProcess( spawnError = error instanceof Error ? error.message : String(error); return 1; }); + const exitCode = await Promise.race([childExit, cleanupFinished]); if (timer) clearTimeout(timer); if (completionPoll) clearInterval(completionPoll); - const processCleanupError = child.pid - ? await (boundedCleanup ?? - activeProcessCleanup.get(child.pid) ?? - terminateProcessGroup(child.pid)) - : null; + // Even a successful launcher exit can leave an already-observed detached + // server behind. Retire that retained tree, never only the root group. + stopChildTree(); + const processCleanupError = await boundedCleanup!; + processOwner.stopObserving(); + // Even a failed direct-child kill must not hold cancellation forever or let + // inherited pipes write into an ended log. The cleanup error remains fatal. + if (processCleanupError) { + child.stdout?.destroy(); + child.stderr?.destroy(); + if (child.exitCode === null && child.signalCode === null) child.unref(); + } if (child.pid) { activeProcessGroups.delete(child.pid); activeProcessCleanup.delete(child.pid); @@ -631,6 +483,7 @@ async function runAttempt(input: { const publishedResults: RunnerE2EResult[] = []; const publishedResultPaths = new Map(); let attemptSecrets: string[] = []; + let processCleanupFailed = false; try { const paperclipHome = path.join(temporaryRoot, "paperclip-home"); const workspace = path.join(temporaryRoot, "workspace"); @@ -742,6 +595,7 @@ async function runAttempt(input: { ), options.ui || options.debug, ); + processCleanupFailed = processResult.processCleanupError !== null; const processFailure = processResult.spawnError ? `Playwright failed to start: ${processResult.spawnError}` : processResult.timedOut @@ -943,9 +797,11 @@ async function runAttempt(input: { } return [...publishedResults]; } finally { - reapNewDetachedDarwinSharedMemory(sharedMemoryBaseline); + if (!processCleanupFailed) reapNewDetachedDarwinSharedMemory(sharedMemoryBaseline); let cleanupError: unknown; - if ( + if (processCleanupFailed) { + cleanupError = new Error(`Preserving temporary state after incomplete process cleanup: ${temporaryRoot}`); + } else if ( temporaryRoot.startsWith(`${os.tmpdir()}${path.sep}paperclip-runner-e2e-`) ) { for (let cleanupAttempt = 1; cleanupAttempt <= 3; cleanupAttempt += 1) { @@ -1100,6 +956,7 @@ async function main() { } await loadLocalEnvironment(process.env); + assertRemoteNativeEvidencePrerequisites(executions, process.env); const missingCredentials = [ ...new Set( executions.flatMap((execution) => execution.requiredCredentials), diff --git a/tests/runner-e2e/live-fixtures.ts b/tests/runner-e2e/live-fixtures.ts index 391bdc6448f..36becfe105d 100644 --- a/tests/runner-e2e/live-fixtures.ts +++ b/tests/runner-e2e/live-fixtures.ts @@ -311,7 +311,7 @@ export async function setupLiveFixtures(input: { }, }); - if (execution.environment.configurationKey === "warm-reuse-v1" + if (execution.task.flow === "warm_three_turn" || (execution.suite.id === "extended-harnesses" && execution.task.id === "file-edit-validate")) { registry.register({ id: "project", diff --git a/tests/runner-e2e/native-active-stop-evidence.ts b/tests/runner-e2e/native-active-stop-evidence.ts new file mode 100644 index 00000000000..81b928b8f9b --- /dev/null +++ b/tests/runner-e2e/native-active-stop-evidence.ts @@ -0,0 +1,235 @@ +import { createHash } from "node:crypto"; +import { canonicalJson } from "../../packages/shared/src/portability-hash.js"; +import { hasAcpxNativeOrigin } from "./acpx-native-origin.js"; +import { isValidNativePrpEnvelope } from "./native-event-envelope.js"; +import { assertNativeRemoteRetirement, nativeRemoteDeniedSample, type NativeRemoteSnapshot } from "./native-remote-evidence.js"; +import { readCursorToolEvidence } from "./cursor-native-evidence.js"; +import { bootstrapReadExecutionId, withoutProvenBootstrapReads, type BootstrapReadProof } from "./native-bootstrap-read-proof.js"; + +type Row = Record; +export interface ActiveStopCaller { type: "board"; userId: "local-board"; source: "local_implicit" } +export type ActiveStopProvider = "cursor"; +export interface ActiveStopScope { provider: ActiveStopProvider; companyId: string; issueId: string; runId: string; target: string; commandSha256?: string } +export interface ActiveStopPending { + schema: "paperclip.e2e.native-active-stop-pending.v2"; + caller: ActiveStopCaller; scope: ActiveStopScope; requestId: string; toolCallId: string; nativeSessionId: string; turnId: string; + normalizedSessionId: string; sourceInstanceId: string; requestSourceSeq: number; permissionSourceSeq: number; + requestRowSha256: string; permissionRowSha256: string; cancellationRequestId: string; observedMonotonicNs: string; + toolOriginSourceSeq: number; toolStartedSourceSeq: number; toolOriginRowSha256: string; toolStartedRowSha256: string; +} +const rec = (v: unknown): Row => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Row : {}; +const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v) && !v.includes("[REDACTED]"); +const uuid = (v: unknown): v is string => typeof v === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/u.test(v); +const hash = (v: unknown) => `sha256:${createHash("sha256").update(canonicalJson(v)).digest("hex")}`; +function fail(condition: unknown, reason: string): asserts condition { if (!condition) throw new Error(`Native active Stop: ${reason}`); } +/** The isolated Product server uses local-trusted board authentication. Bind + * the same public session observed by the API client, never infer a null actor. */ +export function readActiveStopCaller(health: unknown, authentication: unknown): ActiveStopCaller { + const session = rec(rec(authentication).session); + fail(rec(health).deploymentMode === "local_trusted" && session.userId === "local-board" + && session.id === "paperclip:local_implicit:local-board", "expected isolated board caller missing"); + return { type: "board", userId: "local-board", source: "local_implicit" }; +} +function isActiveStopCaller(caller: unknown): caller is ActiveStopCaller { + const value = rec(caller); + return value.type === "board" && value.userId === "local-board" && value.source === "local_implicit"; +} +function canonicalRows(events: readonly unknown[], scope: ActiveStopScope) { + fail(events.length <= 20_000 && events.length > 0, "bounded durable evidence required"); + const rows = events.map(rec).filter(row => rec(row.payload).prpEvent !== undefined); + const seen = new Set(), source = new Set(); + for (const row of rows) { + const e = rec(rec(row.payload).prpEvent); + fail(row.companyId === scope.companyId && row.runId === scope.runId && isValidNativePrpEnvelope(e, row.protocolSchemaVersion) + && e.sourceKind === "runner" && e.runId === scope.runId + && e.eventType === row.eventType && Number.isSafeInteger(row.seq) && row.seq > 0 && !seen.has(row.seq) + && id(e.sourceInstanceId) && Number.isSafeInteger(e.sourceSeq) && e.sourceSeq > 0 + && e.sourceEventId === `${e.sourceInstanceId}:${e.runId}:${e.sourceSeq}` && !source.has(e.sourceEventId), "invalid/duplicate/foreign canonical row"); + seen.add(row.seq); source.add(e.sourceEventId); + } + const ordered = [...rows].sort((a, b) => a.seq - b.seq); + const lastBySource = new Map(); + for (const row of ordered) { + const e = rec(rec(row.payload).prpEvent), previous = lastBySource.get(e.sourceInstanceId) ?? 0; + fail(e.sourceSeq > previous, "durable source sequence reordered"); + lastBySource.set(e.sourceInstanceId, e.sourceSeq); + } + return ordered.map(row => ({ row, event: rec(rec(row.payload).prpEvent) })); +} +const terminals = new Set(["turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"]); +const closures = new Set(["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"]); +function origin(events: readonly unknown[], scope: ActiveStopScope, bootstrap?: BootstrapReadProof) { + fail([scope.companyId, scope.issueId, scope.runId, scope.target].every(id) && ["cursor", "copilot"].includes(scope.provider), "exact scope required"); + const rows = canonicalRows(events, scope); + const notices = readCursorToolEvidence(events, scope.runId); + const requests = notices.filter(n => n.stage === "permission_requested"); + fail(requests.length === 1, "one native permission required"); + const notice = requests[0]!; + fail(id(notice.requestId) && notice.declineOffered === true && (scope.provider === "cursor" + ? notice.operation === "execute" && /^sha256:[a-f0-9]{64}$/u.test(scope.commandSha256 ?? "") && notice.commandSha256 === scope.commandSha256 + : notice.operation === "edit" && "target" in notice && notice.target === scope.target), "exact native operation required"); + const permission = rows.find(x => x.row.seq === notice.seq); + const created = rows.filter(x => x.event.eventType === "runtime_request.created"); + fail(permission && created.length === 1, "one durable native card required"); + const request = rec(rec(created[0]!.event.payload).request), p = permission.event; + fail(request.schema === "paperclip.runtime_request.v2" && request.type === "permission" && request.requestKind === "permission_approval" + && request.status === "pending" && request.requestId === notice.requestId && request.turnId === notice.turnId + && rec(request.details).toolCallId === notice.toolCallId && hasAcpxNativeOrigin(request.origin, scope.provider, "session/request_permission") + && id(p.normalizedSessionId) && p.turnId === notice.turnId && created[0]!.event.turnId === notice.turnId + && created[0]!.event.normalizedSessionId === p.normalizedSessionId && created[0]!.event.sourceInstanceId === p.sourceInstanceId, + "native notice/card identity mismatch"); + const stream = (event: Row) => event.turnId === notice.turnId && event.normalizedSessionId === p.normalizedSessionId && event.sourceInstanceId === p.sourceInstanceId; + fail(rows.filter(x => x.event.turnId != null).every(x => stream(x.event)), "foreign turn/session/source"); + const tool = notices.find(n => n.stage === "tool" && n.toolCallId === notice.toolCallId); + fail(tool, "native operation origin missing"); + const selected = withoutProvenBootstrapReads(notices, tool, bootstrap); + fail(selected.every(n => n.runId === notice.runId && n.sessionId === notice.sessionId && n.turnId === notice.turnId + && n.toolCallId === notice.toolCallId && (n.requestId === undefined || n.requestId === notice.requestId) + && (n.operation === undefined || n.operation === notice.operation) + && (!("target" in n) || n.target === undefined || n.target === scope.target) + && (n.commandSha256 === undefined || n.commandSha256 === scope.commandSha256) + && n.status !== "completed" && (n.stage !== "permission_delivered" || n.outcome === "cancel")), "extra/replayed/answered native operation"); + const tools = selected.filter(n => n.stage === "tool"); + fail(tools.filter(n => n.status === "pending").length === 1 + && tools.filter(n => n.status === "failed").length <= 1 + && tools[0]!.status === "pending" + // Cursor queues permission evidence until its exact command origin exists. + // Copilot projects the request immediately, including permission-first ACP. + && (scope.provider !== "cursor" || tools[0]!.seq < notice.seq) + && selected.filter(n => n.stage === "permission_delivered").length <= 1, "duplicate native operation lifecycle"); + const executions = rows.filter(x => x.event.eventType.startsWith("tool.execution.") && rec(x.event.payload).executionId === bootstrapReadExecutionId(notice.toolCallId)); + fail(executions.filter(x => x.event.eventType === "tool.execution.started").length === 1 + && executions.filter(x => x.event.eventType === "tool.execution.completed").length <= 1 + && executions.every(x => rec(x.event.payload).schema === "paperclip.tool.execution.v1" && rec(x.event.payload).transport === "builtin" + && ["tool.execution.started", "tool.execution.progressed", "tool.execution.completed"].includes(x.event.eventType) + && rec(x.event.payload).status === (x.event.eventType === "tool.execution.completed" ? "failed" : "running")), "duplicate canonical operation lifecycle"); + // ACP permits a permission request before its tool notification. Require + // both in the pre-Stop observation, without imposing a card/start order. + const toolOrigin = rows.find(x => x.row.seq === tools[0]!.seq); + const toolStarted = executions.find(x => x.event.eventType === "tool.execution.started"); + fail(toolOrigin && toolStarted, "native operation origin missing"); + const allowed = new Set(notices.filter(n => n.stage === "tool").map(n => bootstrapReadExecutionId(n.toolCallId))); + fail(rows.filter(x => x.event.eventType.startsWith("tool.execution.")).every(x => allowed.has(rec(x.event.payload).executionId)), "unattributed operation"); + fail(rows.filter(x => x.event.eventType === "tool.execution.completed" && rec(x.event.payload).executionId === bootstrapReadExecutionId(notice.toolCallId)) + .every(x => rec(x.event.payload).status !== "completed"), "target operation completed"); + return { rows, notice, permission, created: created[0]!, toolOrigin, toolStarted, request, stream }; +} +export function observeActiveStopPending(input: { events: readonly unknown[]; run: Row; issue: Row; scope: ActiveStopScope; caller: ActiveStopCaller; cancellationRequestId: string; bootstrap?: BootstrapReadProof }): ActiveStopPending { + const { run, issue, scope } = input, proof = origin(input.events, scope, input.bootstrap); + fail(isActiveStopCaller(input.caller) && uuid(input.cancellationRequestId) && run.id === scope.runId && run.companyId === scope.companyId && run.nativeIssueId === scope.issueId + && run.status === "running" && run.runtimeMode === "native" && issue.id === scope.issueId && issue.companyId === scope.companyId && issue.status === "in_progress" + && run.resultJson?.startupCancellation == null && run.resultJson?.nativeCancellation == null, "run is not fresh active work"); + fail(!(readCursorToolEvidence(input.events, scope.runId)).some(n => n.stage === "permission_delivered"), "permission already answered"); + fail(!proof.rows.some(x => terminals.has(x.event.eventType) || closures.has(x.event.eventType)), "request or provider already settled"); + fail(!proof.rows.some(x => x.event.eventType === "tool.execution.completed" && rec(x.event.payload).executionId === bootstrapReadExecutionId(proof.notice.toolCallId)), "operation already ended"); + return { schema: "paperclip.e2e.native-active-stop-pending.v2", scope, caller: input.caller, requestId: proof.notice.requestId!, toolCallId: proof.notice.toolCallId, + nativeSessionId: proof.notice.sessionId, turnId: proof.notice.turnId, normalizedSessionId: proof.permission.event.normalizedSessionId, + sourceInstanceId: proof.permission.event.sourceInstanceId, requestSourceSeq: proof.created.event.sourceSeq, permissionSourceSeq: proof.permission.event.sourceSeq, + requestRowSha256: hash(proof.created.row), permissionRowSha256: hash(proof.permission.row), cancellationRequestId: input.cancellationRequestId, + toolOriginSourceSeq: proof.toolOrigin.event.sourceSeq, toolStartedSourceSeq: proof.toolStarted.event.sourceSeq, + toolOriginRowSha256: hash(proof.toolOrigin.row), toolStartedRowSha256: hash(proof.toolStarted.row), + observedMonotonicNs: process.hrtime.bigint().toString() }; +} +export function readActiveStopSettlement(input: { events: readonly unknown[]; run: Row; issue: Row; pending: ActiveStopPending; dispatchMonotonicNs: string; bootstrap?: BootstrapReadProof }) { + const before = input.pending, { run, issue } = input; + fail(before?.schema === "paperclip.e2e.native-active-stop-pending.v2" && isActiveStopCaller(before.caller) && uuid(before.cancellationRequestId) + && /^[1-9][0-9]{0,29}$/u.test(before.observedMonotonicNs) && /^[1-9][0-9]{0,29}$/u.test(input.dispatchMonotonicNs) + && BigInt(before.observedMonotonicNs) < BigInt(input.dispatchMonotonicNs), "pre-dispatch pending observation missing"); + const proof = origin(input.events, before.scope, input.bootstrap); + fail(hash(proof.created.row) === before.requestRowSha256 && hash(proof.permission.row) === before.permissionRowSha256 + && hash(proof.toolOrigin.row) === before.toolOriginRowSha256 && hash(proof.toolStarted.row) === before.toolStartedRowSha256 + && proof.toolOrigin.event.sourceSeq === before.toolOriginSourceSeq && proof.toolStarted.event.sourceSeq === before.toolStartedSourceSeq + && proof.notice.sessionId === before.nativeSessionId && proof.notice.turnId === before.turnId && proof.notice.toolCallId === before.toolCallId + && proof.notice.requestId === before.requestId && proof.permission.event.normalizedSessionId === before.normalizedSessionId + && proof.permission.event.sourceInstanceId === before.sourceInstanceId && proof.created.event.sourceSeq === before.requestSourceSeq + && proof.permission.event.sourceSeq === before.permissionSourceSeq, "observed pending request changed"); + const closed = proof.rows.filter(x => closures.has(x.event.eventType)), terminal = proof.rows.filter(x => terminals.has(x.event.eventType)); + fail(closed.length === 1 && terminal.length === 1, "one closed request and one terminal required"); + const c = closed[0]!, t = terminal[0]!, cp = rec(c.event.payload), tp = rec(t.event.payload); + // Product persists the harness projection, not the raw runnerd backend + // payload. The terminal mapper closes pending permissions with turn_terminal + // and emits {status,error}. Bind that exact boundary to the retained native + // request and caller-owned Stop below; a generic terminal is insufficient. + fail(proof.stream(c.event) && proof.stream(t.event) && c.event.eventType === "runtime_request.cancelled" && cp.requestId === before.requestId && cp.turnId === before.turnId + && cp.requestKind === "permission_approval" && cp.reason === "turn_terminal" + && cp.itemId === proof.request.itemId && cp.action === undefined && cp.response === undefined && cp.replayAllowed !== true + && t.event.eventType === "turn.cancelled" && tp.status === "cancelled" && tp.error === null + && c.event.sourceSeq > Math.max(before.requestSourceSeq, before.permissionSourceSeq, before.toolOriginSourceSeq, before.toolStartedSourceSeq) + && t.event.sourceSeq > c.event.sourceSeq, + "pending callback did not close through cancellation"); + fail(proof.rows.filter(x => x.event.eventType.startsWith("tool.execution.") + || (x.event.eventType === "provider.notice.recorded" && ["cursor_tool_evidence_v1", "copilot_tool_evidence_v1"].includes(rec(x.event.payload).category))) + .every(x => x.event.sourceSeq < t.event.sourceSeq), "native operation after provider cancellation"); + const stop = rec(rec(run.resultJson).nativeCancellation), startup = rec(rec(run.resultJson).startupCancellation); + fail(run.id === before.scope.runId && run.companyId === before.scope.companyId && run.nativeIssueId === before.scope.issueId && run.runtimeMode === "native" && run.status === "cancelled" + && issue.id === before.scope.issueId && issue.companyId === before.scope.companyId && issue.status === "in_progress" + && rec(startup.requestedBy).type === before.caller.type && rec(startup.requestedBy).userId === before.caller.userId + && startup.cancellationRequestId === before.cancellationRequestId && stop.intentId === `native-cancellation:${before.cancellationRequestId}` + && stop.schema === "paperclip.native-cancellation.v1" && stop.companyId === before.scope.companyId && stop.runId === run.id && stop.issueId === issue.id + && stop.scope === "run" && stop.dispatched === true && stop.dispatchState === "acknowledged" && stop.reasonCode === "cancellation_run_only" + && Array.isArray(stop.effects) && stop.effects.length === 1 && stop.effects[0] === "release_run_resources" + && id(stop.intentAuditId) && id(stop.acknowledgementAuditId) && stop.intentAuditId !== stop.acknowledgementAuditId, + "same-scope caller Stop acknowledgement missing"); + return { schema: "paperclip.e2e.native-active-stop-settlement.v2", pending: before, dispatchMonotonicNs: input.dispatchMonotonicNs, + branch: "pending_permission_cancelled" as const, closedRequestSourceSeq: c.event.sourceSeq, terminalSourceSeq: t.event.sourceSeq, + closedRequestRowSha256: hash(c.row), terminalRowSha256: hash(t.row), intentId: stop.intentId as string, + intentAuditId: stop.intentAuditId as string, acknowledgementAuditId: stop.acknowledgementAuditId as string, + taskStillInProgress: true, normalCompletionAccepted: false, replayAllowed: false }; +} + +export interface ActiveStopRemoteObservation { + phase: "before-request" | "pending" | "owned-process-retirement"; + source: "live-snapshot" | "retirement-seal"; + snapshot: NativeRemoteSnapshot; +} + +/** A per-turn Daytona observer seals itself when the owned tree retires. The + * host may retrieve that receipt later; retrieval is not a fresh observation. */ +export function readActiveStopRemoteRetirement(input: { + scope: Pick; observations: readonly ActiveStopRemoteObservation[]; +}) { + const observations = input.observations; + fail(observations.length === 3 + && observations.map(o => `${o.phase}:${o.source}`).join(",") === "before-request:live-snapshot,pending:live-snapshot,owned-process-retirement:retirement-seal", + "remote seal cannot stand in for a fresh causal sample"); + const [baseline, pending, terminal] = observations.map(o => o.snapshot) as [NativeRemoteSnapshot, NativeRemoteSnapshot, NativeRemoteSnapshot]; + fail(baseline.binding.companyId === input.scope.companyId && baseline.binding.runId === input.scope.runId, "remote observation belongs to another run"); + for (const snapshot of [baseline, pending, terminal]) { + fail(!nativeRemoteDeniedSample(snapshot, baseline, input.scope.target, "pending").exists, "remote target changed"); + } + fail(BigInt(baseline.observedMonotonicNs) < BigInt(pending.observedMonotonicNs) + && BigInt(pending.observedMonotonicNs) < BigInt(terminal.observedMonotonicNs), "remote observation reused or out of order"); + fail(!baseline.setup.published && pending.setup.published && pending.setup.sha256 === terminal.setup.sha256 + && baseline.processes.captured && pending.processes.captured + && baseline.processes.live.includes(baseline.processes.root?.pid ?? -1) + && pending.processes.live.includes(pending.processes.root?.pid ?? -1), "remote pending lifetime unproven"); + assertNativeRemoteRetirement(terminal, baseline); + assertNativeRemoteRetirement(terminal, pending); + for (const snapshot of [baseline, pending]) { + fail(snapshot.processes.journal.every(p => terminal.processes.journal.some(q => + p.pid === q.pid && p.startTicks === q.startTicks && p.bootId === q.bootId)), "remote descendant journal lost"); + } + return { schema: "paperclip.e2e.native-active-stop-remote-retirement.v1" as const, + coverage: "continuous-through-owned-process-retirement" as const, filesystemAfterRetirementObserved: false as const, + binding: terminal.binding, target: input.scope.target, + observations: observations.map(o => ({ phase: o.phase, source: o.source, + observedMonotonicNs: o.snapshot.observedMonotonicNs, snapshotSha256: hash(o.snapshot) })) }; +} + +/** Local files remain accessible through teardown. Remote per-turn files do + * not: require the complete lifetime seal, never relabel it as a later sample. */ +export function assertActiveStopRetirement(input: { + completed: boolean; identityChanged: boolean; + processes: { captured: boolean; live: readonly number[] }; + watcher: { complete: boolean; targetMutationCount: number }; +} & ({ environment: "local"; samples: ReadonlyArray<{ phase: string; absent: boolean }> } + | { environment: "daytona"; remote: Parameters[0] })) { + fail(input.completed && !input.identityChanged && input.processes.captured + && input.processes.live.length === 0 && input.watcher.complete && input.watcher.targetMutationCount === 0, + "retirement or continuous no-effect proof is incomplete"); + if (input.environment === "daytona") return readActiveStopRemoteRetirement(input.remote); + fail(input.environment === "local" && input.samples.length === 4 && input.samples.every(s => s.absent === true) + && input.samples.map(s => s.phase).join(",") === "before-request,pending,after-stop,after-cleanup", + "causal target observation missing or changed"); + return { coverage: "local-through-cleanup" as const }; +} diff --git a/tests/runner-e2e/native-active-stop-flow.ts b/tests/runner-e2e/native-active-stop-flow.ts new file mode 100644 index 00000000000..f1ff9d172eb --- /dev/null +++ b/tests/runner-e2e/native-active-stop-flow.ts @@ -0,0 +1,200 @@ +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { expect, type Page } from "@playwright/test"; +import { pollUntil, type RunnerApi } from "./api.js"; +import { collectRunEvents } from "./run-observations.js"; +import { createTaskThroughUi } from "./user-actions.js"; +import { createDeniedTargetFixture, exists, observeRunProcesses } from "./native-local-fixtures.js"; +import { assertNativeRemoteRetirement, nativeRemoteDeniedSample, prepareNativeRemoteAction, type NativeRemoteBootstrap, type NativeRemoteFixture, type NativeRemoteSnapshot } from "./native-remote-evidence.js"; +import { cursorDeniedCommand } from "./cursor-native-evidence.js"; +import { assertActiveStopRetirement, readActiveStopCaller, observeActiveStopPending, readActiveStopSettlement, type ActiveStopRemoteObservation, type ActiveStopCaller, type ActiveStopPending, type ActiveStopScope } from "./native-active-stop-evidence.js"; +import type { BootstrapReadProof } from "./native-bootstrap-read-proof.js"; +import type { LiveFixtureValues } from "./live-fixtures.js"; +import type { MatrixExecution } from "./types.js"; +type Row = Record; +type Check = { id: string; passed: boolean; detail: string }; +export interface ActiveStopState { run: Row; issue: Row; events: readonly unknown[] } +/** Retain a real pending API observation before the only mutating request. + * Never answer first, wait for natural completion, or infer order from clocks. */ +export async function stopAtPendingPermission(input: { + scope: ActiveStopScope; caller: ActiveStopCaller; bootstrap?: BootstrapReadProof; deadlineAt: number; + load(): Promise; + retain(receipt: ActiveStopPending): Promise; + stop(runId: string, cancellationRequestId: string): Promise; +}) { + const cancellationRequestId = randomUUID(); + const state = await input.load(); + const pending = observeActiveStopPending({ ...state, scope: input.scope, caller: input.caller, cancellationRequestId, bootstrap: input.bootstrap }); + await input.retain(pending); + const fresh = observeActiveStopPending({ ...await input.load(), scope: input.scope, caller: input.caller, cancellationRequestId, bootstrap: input.bootstrap }); + if (fresh.requestRowSha256 !== pending.requestRowSha256 || fresh.permissionRowSha256 !== pending.permissionRowSha256 + || fresh.toolOriginRowSha256 !== pending.toolOriginRowSha256 || fresh.toolStartedRowSha256 !== pending.toolStartedRowSha256 + || Date.now() >= input.deadlineAt) throw new Error("Native active Stop pending boundary changed or expired"); + const dispatchMonotonicNs = process.hrtime.bigint().toString(); + const stopped = await input.stop(input.scope.runId, cancellationRequestId); + if (stopped.id !== input.scope.runId || stopped.resultJson?.nativeCancellation?.intentId !== `native-cancellation:${cancellationRequestId}`) throw new Error("Native active Stop response has a foreign intent"); + const label = "cancelled unanswered native permission"; + const final = await pollUntil({ label, deadlineAt: input.deadlineAt, intervalMs: 200, load: async () => { + const value = await input.load(); + if (["succeeded", "failed", "timed_out"].includes(value.run.status)) throw new Error(`Stopped waiting for ${label}: unexpected run terminal`); + return value; + }, accept: value => { readActiveStopSettlement({ ...value, pending, dispatchMonotonicNs, bootstrap: input.bootstrap }); return true; } }); + return { pending, dispatchMonotonicNs, settlement: readActiveStopSettlement({ ...final, pending, dispatchMonotonicNs, bootstrap: input.bootstrap }) }; +} +export async function runNativeActiveStopFlow(input: { + page: Page; api: RunnerApi; fixtures: LiveFixtureValues; execution: MatrixExecution; nonce: string; workspacePath: string; deadlineAt: number; + remoteBootstrap?: NativeRemoteBootstrap; + registerCleanupAssertion(callback: () => Promise): void; + registerBeforeEnvironmentTeardownAssertion(callback: () => Promise): void; + observe(issue: Row, runs: Row[]): void; + capture(id: string, label: string, file: string): Promise; + evidence(name: string, value: unknown): Promise; +}) { + const { api, page, fixtures, execution, nonce } = input, provider = execution.profile.qualificationCandidate; + if (provider !== "cursor" || execution.task.id !== "pending-permission-stop") throw new Error("Unknown native active Stop case"); + const remote = execution.environment.id === "daytona"; + if ((!remote && execution.environment.id !== "local") || (remote && !input.remoteBootstrap)) throw new Error("Active Stop requires an isolated admitted environment"); + const checks: Check[] = []; let issue: Row = {}, runs: Row[] = [], events: Row[] = []; + const check = (id: string, passed: boolean, detail: string) => { checks.push({ id, passed, detail }); expect(passed, detail).toBe(true); }; + const name = `active-stop-${nonce}.txt`, local = remote ? undefined : await createDeniedTargetFixture(input.workspacePath, name); + const target = local?.targetRelativePath ?? name; + let command = provider === "cursor" && local ? cursorDeniedCommand(local.targetPath) : undefined; + const observer = remote ? undefined : observeRunProcesses(); + let processes: { captured: boolean; live: number[] } = { captured: false, live: [] }; + let processIdentity: string | undefined, processError = false; + const remoteObservations: ActiveStopRemoteObservation[] = []; + let retirement: ReturnType | undefined; + const samples: Array<{ phase: string; absent: boolean }> = []; + let fixture: NativeRemoteFixture | undefined, baseline: NativeRemoteSnapshot | undefined, sealed: NativeRemoteSnapshot | undefined; + let completed: Awaited> | undefined; + const observeProcesses = () => { + const run = runs[0], authority = run?.processPid ? { pid: run.processPid, groupId: run.processGroupId, startedAt: run.processStartedAt, runId: run.id } : undefined; + if (authority) { const key = JSON.stringify(authority); if (processIdentity && processIdentity !== key) processError = true; processIdentity ??= key; } + if (observer) processes = observer.sample(authority); + }; + const load = async (): Promise => { + if (issue.id) issue = await api.get(`/api/issues/${issue.id}`); + const list = await api.get(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`); + runs = await Promise.all(list.map(r => api.get(`/api/heartbeat-runs/${r.id}`))); + if (runs.length > 1) throw new Error("Native active Stop dispatched an extra run"); + events = runs[0] ? await collectRunEvents((afterSeq, limit) => api.get(`/api/heartbeat-runs/${runs[0]!.id}/events?afterSeq=${afterSeq}&limit=${limit}`)) : []; + observeProcesses(); input.observe(issue, runs); return { run: runs[0] ?? {}, issue, events }; + }; + const sample = async (phase: string) => { + let value: boolean; + if (remote) { + if (!fixture || !baseline) throw new Error("Missing remote active Stop observer"); + if (phase !== "pending") throw new Error("Remote filesystem phase requires a fresh live snapshot"); + const snap = await fixture.snapshot(phase); + remoteObservations.push({ phase, source: "live-snapshot", snapshot: snap }); + value = !nativeRemoteDeniedSample(snap, baseline, target, "pending").exists; + await input.evidence(`active-stop-${phase}-remote.json`, snap); + } else value = !await exists(local!.targetPath); + samples.push({ phase, absent: value }); check(`no-effect-${phase}`, value, "Exact target remains absent at the causal observation boundary"); + }; + const scope = (): ActiveStopScope => ({ provider, companyId: fixtures.company.id, issueId: issue.id, runId: runs[0]!.id, target, ...(command ? { commandSha256: command.commandSha256 } : {}) }); + const bootstrap = (): BootstrapReadProof | undefined => fixture ? { actionFile: fixture.actionFile, events } : undefined; + const cleanup = async () => { + const receipt: Check[] = []; + try { + await load(); + if (remote) { + if (!fixture || !baseline) throw new Error("Remote observer was never armed"); + sealed ??= await fixture.finish(); assertNativeRemoteRetirement(sealed, baseline); processes = sealed.processes; + } else if (processes.captured && processes.live.length) { + await pollUntil({ label: "active Stop provider retirement", deadlineAt: Date.now() + 5000, intervalMs: 100, load: async () => { observeProcesses(); return processes; }, accept: p => p.live.length === 0 }); + } + if (!remote) await sample("after-cleanup"); + const watch = remote ? sealed!.watcher : local!.watcher.finish(); + retirement = assertActiveStopRetirement({ completed: Boolean(completed), identityChanged: processError, processes, watcher: watch, + ...(remote ? { environment: "daytona" as const, remote: { scope: scope(), observations: remoteObservations } } : { environment: "local" as const, samples }) }); + if (!completed) throw new Error("Missing active Stop settlement"); + readActiveStopSettlement({ ...await load(), ...completed, bootstrap: bootstrap() }); + receipt.push({ id: "active-stop-retired-no-effects", passed: true, detail: remote ? "Exact run root/descendants retired with continuous no-effects proof through retirement; later UI/API checks do not claim fresh filesystem observation" : "Exact run root/descendants retired; unanswered target remained absent through cleanup" }); + return receipt; + } finally { + if (timer) clearInterval(timer); + try { + await input.evidence("native-active-stop-cleanup.json", { processes, processError, samples, remoteObservations, retirement, sealed, watcher: local?.watcher.finish(), checks: receipt }); + } finally { + if (remote) await fixture?.close(); + } + } + }; + const timer = observer ? setInterval(() => { try { observeProcesses(); } catch { processError = true; } }, 100) : undefined; + (remote ? input.registerBeforeEnvironmentTeardownAssertion : input.registerCleanupAssertion)(cleanup); + const prompt = () => provider === "cursor" + ? `${execution.task.buildPrompt(nonce)}\nUse this exact native shell command once:\n${command!.command}` + : `${execution.task.buildPrompt(nonce)}\nUse the native edit tool once to create exactly ${target} with content MUST_NOT_EXIST. Do not use a shell command.`; + try { + const caller = readActiveStopCaller(await api.get("/api/health"), await api.get("/api/auth/get-session")); + const agent = await api.get(`/api/agents/${fixtures.agent.id}`); + const configured = await api.patch(`/api/agents/${fixtures.agent.id}`, { adapterConfig: { ...agent.adapterConfig, acpxPermissionMode: "approve-reads", ...(provider === "cursor" ? { acpxSessionMode: "agent" } : {}), timeoutSec: 120, lifecycleMode: "per_turn" } }); + check("explicit-per-turn-policy", configured.adapterConfig?.acpxPermissionMode === "approve-reads" && configured.adapterConfig?.lifecycleMode === "per_turn" && (provider !== "cursor" || configured.adapterConfig?.acpxSessionMode === "agent"), "Agent mode and per-turn restrictive permission policy selected before startup"); + const project = await api.post(`/api/companies/${fixtures.company.id}/projects`, { name: `Native active Stop ${nonce}`, executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", sharedWorkspaceConcurrency: "serialize", allowIssueOverride: false, environmentId: fixtures.environment.id, workspaceStrategy: { type: "project_primary" } }, workspace: { name: "Primary", sourceType: "local_path", cwd: input.workspacePath, isPrimary: true } }); + if (!remote) await sample("before-request"); + await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : prompt(), workMode: "standard", projectName: project.name }); + issue = (await pollUntil({ label: "browser-created active Stop task", deadlineAt: input.deadlineAt, load: async () => (await api.get(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(i => i.title === execution.task.buildTitle(nonce)), accept: Boolean }))!; + if (remote) { + await pollUntil({ label: "active Stop remote bootstrap", deadlineAt: input.deadlineAt, load, accept: state => state.run.status === "running" }); + const bound = await input.remoteBootstrap!.bindAndRelease({ issueId: issue.id, runId: runs[0]!.id, targets: [target], actionPrompt: async value => { + fixture = value; if (provider === "cursor") command = cursorDeniedCommand(join(value.remoteCwd, target)); + const prepared = await prepareNativeRemoteAction({ fixture: value, companyId: fixtures.company.id, environmentId: fixtures.environment.id, runId: runs[0]!.id, target, prompt: prompt() }); + baseline = prepared.baseline; + remoteObservations.push({ phase: "before-request", source: "live-snapshot", snapshot: baseline }); + await input.evidence("active-stop-before-request-remote.json", baseline); return prepared.prompt; + } }); + if (bound !== fixture) throw new Error("Remote active Stop publication identity changed"); + } + const label = "unanswered native permission"; + await pollUntil({ label, deadlineAt: input.deadlineAt, intervalMs: 200, load: async () => { + const state = await load(); if (["failed", "timed_out", "cancelled", "succeeded"].includes(state.run.status)) throw new Error(`Stopped waiting for ${label}: provider ended without pending callback`); return state; + }, accept: state => { observeActiveStopPending({ ...state, scope: scope(), caller, cancellationRequestId: randomUUID(), bootstrap: bootstrap() }); return true; } }); + await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`); + const card = page.getByTestId("task-chat-runtime-request").filter({ visible: true }); + await expect(card).toHaveCount(1); await expect(card.getByRole("button", { name: "Deny", exact: true })).toBeEnabled(); + await input.capture("pending-permission", "Native permission left unanswered before Stop", "pending-permission.png"); await sample("pending"); + completed = await stopAtPendingPermission({ scope: scope(), caller, bootstrap: bootstrap(), deadlineAt: input.deadlineAt, load, + retain: proof => input.evidence("native-active-stop-pending.json", proof), stop: (runId, cancellationRequestId) => api.post(`/api/heartbeat-runs/${runId}/cancel`, { cancellationRequestId }) }); + check("pending-callback-cancelled", true, "Unanswered native permission closed through a cancelled provider turn and the exact caller-owned Stop acknowledgement"); + await input.evidence("native-active-stop-settlement.json", completed); + // Only after confirmed cancellation: test the real public stale-response fence. + const response = await api.request.post(`/api/heartbeat-runs/${runs[0]!.id}/runtime-requests/${encodeURIComponent(completed.pending.requestId)}/resolve`, { data: { turnId: completed.pending.turnId, requestKind: "permission_approval", resolution: { action: "accept" } } }); + check("stale-answer-refused", response.status() === 409, "Stopped permission rejects a later answer rather than replaying work"); + await load(); readActiveStopSettlement({ ...await load(), ...completed, bootstrap: bootstrap() }); + if (remote) { + // finish drains the observer's automatic retirement seal. It does not + // extend the remote watch through subsequent host UI/cleanup assertions. + sealed = await fixture!.finish(); assertNativeRemoteRetirement(sealed, baseline!); processes = sealed.processes; + remoteObservations.push({ phase: "owned-process-retirement", source: "retirement-seal", snapshot: sealed }); + await input.evidence("active-stop-owned-process-retirement-remote.json", sealed); + } else await sample("after-stop"); + await page.reload(); + const finalUiTimeout = () => { + const remaining = input.deadlineAt - Date.now(); + if (remaining <= 0) throw new Error("Active Stop final UI deadline elapsed"); + return Math.min(30_000, remaining); + }; + // Absence of a permission button during React loading is not proof that a + // stopped request is unanswerable. First observe the actual task/run UI. + await expect(page.getByTestId("issue-detail-header").getByRole("button", { + name: "Change status (current: In Progress)", exact: true, + })).toBeVisible({ timeout: finalUiTimeout() }); + await expect(page.getByTestId("task-chat-thread").getByTestId("task-chat-collapsible-marker") + .filter({ has: page.getByText("Run cancelled", { exact: true }) })) + .toBeVisible({ timeout: finalUiTimeout() }); + await expect(page.getByTestId("task-chat-history-loading")) + .toHaveCount(0, { timeout: finalUiTimeout() }); + await expect(card.getByRole("button", { name: "Deny", exact: true })) + .toHaveCount(0, { timeout: finalUiTimeout() }); + await load(); readActiveStopSettlement({ ...await load(), ...completed, bootstrap: bootstrap() }); + check("one-unfinished-cancelled-run", runs.length === 1 && issue.status === "in_progress" && runs[0]!.status === "cancelled", "No automatic follow-up run or false task completion"); + await input.capture("final-state", "Stopped native permission is no longer answerable", "final-state.png"); + await input.evidence("api-state.json", { issue, run: runs[0], runs, checks, samples, remoteObservations, runEvents: events, runEventsByRun: [{ runId: runs[0]!.id, events }], activeStop: completed }); + return { issue, runs, checks }; + } finally { + // Local observation continues through cleanup. Remote cleanup revalidates + // the lifetime seal and fresh API state without claiming later file reads. + await input.evidence("native-active-stop-checks.json", { issue, runs, checks, samples, remoteObservations, completed }); + } +} diff --git a/tests/runner-e2e/native-active-stop-tasks.ts b/tests/runner-e2e/native-active-stop-tasks.ts new file mode 100644 index 00000000000..3446ef501de --- /dev/null +++ b/tests/runner-e2e/native-active-stop-tasks.ts @@ -0,0 +1,10 @@ +import type { RunnerTaskFixture } from "./types.js"; +export const nativeActiveStopTasks: readonly RunnerTaskFixture[] = [{ + id: "pending-permission-stop", label: "Stop while native permission is unanswered", groups: [], workMode: "standard", flow: "native_active_stop", + expectedRunCount: 1, attemptTimeoutMs: { local: 300_000, daytona: 300_000 }, turnTimeoutMs: 120_000, + expectedTerminalState: { issue: "in_progress", run: "cancelled" }, + buildTitle: nonce => `Native pending permission Stop ${nonce}`, + buildVisibleMarker: nonce => `NATIVE-ACTIVE-STOP-${nonce}`, + buildPrompt: () => "Attempt the supplied exact native operation once. Leave its native permission callback pending for the board operator. Do not answer, reject or cancel your own permission; do not retry, use another tool/path, call paperclip_finish, or claim completion. The operator will stop this unfinished task while permission is still unanswered.", + buildMatchers: () => [], // Dedicated flow requires pending API observation, caller-owned Stop and retirement. +}]; diff --git a/tests/runner-e2e/native-active-stop.test.ts b/tests/runner-e2e/native-active-stop.test.ts new file mode 100644 index 00000000000..63eab1a5a6b --- /dev/null +++ b/tests/runner-e2e/native-active-stop.test.ts @@ -0,0 +1,451 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; +import { assertActiveStopRetirement, readActiveStopRemoteRetirement, type ActiveStopRemoteObservation, readActiveStopCaller, observeActiveStopPending, readActiveStopSettlement, type ActiveStopProvider } from "./native-active-stop-evidence.js"; +import { stopAtPendingPermission } from "./native-active-stop-flow.js"; +import { runnerMatrix, runnerSuites, suiteDefinitionHash } from "./catalog.js"; +import { selectRunnerExecutions, parseRunnerSelectors } from "./selectors.js"; +import { createCursorToolEvidence } from "../../packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.js"; +import type { CanonicalProviderEvent } from "../../packages/paperclip-runner/src/provider-events.js"; + +import { CodexSessionState } from "../../packages/paperclip-runner/src/drivers/codex/codex-session-state.js"; +import { mapTerminalTurn } from "../../packages/paperclip-runner/src/drivers/codex/codex-session-terminal.js"; + +const caller = readActiveStopCaller({ deploymentMode: "local_trusted" }, { session: { userId: "local-board", id: "paperclip:local_implicit:local-board" } }); +const cancellationRequestId = "11111111-2222-4333-8444-555555555555"; +type Row = Record; +function fixture(provider: ActiveStopProvider = "cursor") { + const scope = { provider, companyId: "company", issueId: "issue", runId: "run", target: "target.txt", ...(provider === "cursor" ? { commandSha256: `sha256:${"a".repeat(64)}` } : {}) }; + const row = (seq: number, eventType: string, payload: Row): Row => ({ companyId: "company", runId: "run", seq, eventType, protocolSchemaVersion: 1, + payload: { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", eventType, runId: "run", turnId: "turn", + normalizedSessionId: "normalized-session", sourceInstanceId: "source", sourceSeq: seq, sourceEventId: `source:run:${seq}`, emittedAt: "2026-01-01T00:00:00Z", payload } } }); + const notice = (seq: number, stage: string, fields: Row) => row(seq, "provider.notice.recorded", { + schema: "paperclip.provider.notice.v1", scope: "turn", category: `${provider}_tool_evidence_v1`, + provenance: { sessionId: "native-session", turnId: "turn", eventType: stage, method: stage === "tool" ? "session/update" : "session/request_permission" }, + details: Object.entries({ stage, toolCallId: "tool", ...fields }).map(([name, value]) => ({ name, value: String(value) })), + }); + const operation = provider === "cursor" ? { operation: "execute", commandSha256: scope.commandSha256 } : { operation: "edit", target: scope.target }; + const events: Row[] = [ + notice(1, "tool", { status: "pending", ...operation }), + row(2, "tool.execution.started", { schema: "paperclip.tool.execution.v1", executionId: "tool", transport: "builtin", status: "running", ...operation }), + notice(3, "permission_requested", { requestId: "request", declineOffered: true, ...operation }), + row(4, "runtime_request.created", { request: { schema: "paperclip.runtime_request.v2", requestKind: "permission_approval", type: "permission", status: "pending", requestId: "request", turnId: "turn", itemId: "item-run", method: "session/request_permission", + details: { toolCallId: "tool" }, origin: { adapter: "acpx-runtime-sidecar", provider, method: "session/request_permission" } } }), + ]; + const run: Row = { id: "run", companyId: "company", nativeIssueId: "issue", runtimeMode: "native", status: "running", resultJson: {} }; + const issue = { id: "issue", companyId: "company", status: "in_progress" }; + const state = () => ({ events, run, issue }); + const pending = () => observeActiveStopPending({ ...state(), scope, caller, cancellationRequestId }); + const permissionResponse = vi.fn(); + const settle = (requestId = cancellationRequestId) => { + // Exercise the actual Product terminal and pending-request producers. Only + // the state storage/emitter and callback are test doubles; no provider runs. + const request = events.find(event => event.eventType === "runtime_request.created")!.payload.prpEvent.payload.request; + const state = { + terminalTurns: new Map(), workspaceChangesByTurn: new Map(), result: null, + conversationMode: "direct", activeTurnId: "turn", turnStarted: true, + pendingRuntimeRequestMap: new Map([[request.requestId, { request, settle: permissionResponse }]]), + cancelPendingRequests: CodexSessionState.prototype.cancelPendingRequests, + emit: (eventType: string, value: Row) => events.push(row(events.length + 1, eventType, value)), + } as unknown as CodexSessionState; + mapTerminalTurn(state, { id: "turn", status: "cancelled", error: null }, "turn"); + run.status = "cancelled"; run.resultJson = { startupCancellation: { cancellationRequestId: requestId, requestedBy: { type: "board", userId: "local-board" } }, nativeCancellation: { + schema: "paperclip.native-cancellation.v1", intentId: `native-cancellation:${requestId}`, companyId: "company", runId: "run", issueId: "issue", scope: "run", + dispatched: true, dispatchState: "acknowledged", reasonCode: "cancellation_run_only", effects: ["release_run_resources"], intentAuditId: "audit-intent", acknowledgementAuditId: "audit-ack", + } }; + return run; + }; + return { scope, events, run, issue, row, notice, state, pending, settle, permissionResponse }; +} +const frame = (row: Row) => row.payload.prpEvent; +const payload = (row: Row) => frame(row).payload; +const arrivalOrders = ["tool-first", "permission-first"] as const; +function withArrivalOrder(provider: ActiveStopProvider, order: typeof arrivalOrders[number]) { + const f = fixture(provider); + if (order === "permission-first") { + // Isolate canonical card/start order in these synthetic mutation cases. + // Actual provider-specific notice ordering is exercised by the projectors. + f.events.unshift(f.events.pop()!); + f.events.forEach((row, index) => { + row.seq = frame(row).sourceSeq = index + 1; + frame(row).sourceEventId = `source:run:${index + 1}`; + }); + } + return f; +} +function withProjectedArrivalOrder(provider: ActiveStopProvider, order: typeof arrivalOrders[number]) { + const f = fixture(provider), start = payload(f.events[1]!), card = payload(f.events[3]!); + const command = "printf MUST_NOT_EXIST > target.txt"; + if (provider === "cursor") { + f.scope.commandSha256 = `sha256:${createHash("sha256").update(command).digest("hex")}`; + start.commandSha256 = f.scope.commandSha256; + } + f.events.length = 0; + const append = (eventType: string, value: Row) => f.events.push(f.row(f.events.length + 1, eventType, value)); + const projector = (createCursorToolEvidence)({ + sessionId: "native-session", turnId: "turn", workingDirectory: "/fixture", active: () => true, + emit: (event: CanonicalProviderEvent) => { append(event.eventType, event.payload); }, + }); + const tool = { type: "tool_call", tag: "tool_call", toolCallId: "tool", status: "pending", + ...(provider === "cursor" ? { kind: "execute", rawInput: { command } } : { kind: "edit", rawInput: { fileName: "target.txt" } }), + }; + let delivered: ((outcome: string) => void) | undefined, beforeTool: Row[] | undefined; + const permission = () => { + delivered = projector.permission({ raw: { sessionId: "native-session", toolCall: provider === "cursor" + ? { toolCallId: "tool", kind: "execute" } : { toolCallId: "tool", kind: "edit", rawInput: tool.rawInput } } }, "request", ["decline", "cancel"]); + // Match the sidecar: invoke the evidence projector before publishing the + // permission request; project each iterator tool before its canonical row. + append("runtime_request.created", card); + }; + const notification = () => { projector.tool(tool); append("tool.execution.started", start); }; + if (order === "permission-first") { permission(); beforeTool = structuredClone(f.events); notification(); } + else { notification(); permission(); } + if (!delivered) throw new Error("Native permission projector rejected the fixture"); + return { ...f, beforeTool, delivered, nativeStageOrder: () => f.events.filter(row => row.eventType === "provider.notice.recorded").map(row => payload(row).provenance.eventType) }; +} +function settled() { const f = fixture(); const pending = f.pending(); f.settle(); return { f, pending, read: () => readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() }) }; } +function withSessionPrefix(provider: ActiveStopProvider = "cursor") { + const f = fixture(provider); + // The retained failed attempt contained v1 session.started followed by these + // v2 session events before its v1 tool/permission events. Keep that shape; + // this synthetic fixture supplies the otherwise required native tool proof. + for (const row of f.events) { + row.seq += 30; frame(row).sourceSeq += 3; + frame(row).sourceEventId = `source:run:${frame(row).sourceSeq}`; + } + const prefix = [f.row(17, "session.started", {}), + f.row(19, "session.capabilities.updated", { sessionGoals: null }), + f.row(21, "session.goal.snapshot", { goal: null, workingNow: false })]; + prefix.forEach((row, index) => { + const e = frame(row); e.turnId = null; e.sourceSeq = index + 1; e.sourceEventId = `source:run:${index + 1}`; + if (index > 0) { row.protocolSchemaVersion = 2; e.schema = "paperclip.prp.event.v2"; e.schemaVersion = 2; } + }); + f.events.unshift(...prefix); + return f; +} + +describe("definitely active native permission Stop", () => { + describe.each(["cursor"] as const)("%s pre-Stop arrival orders", provider => { + it.each(arrivalOrders)("accepts %s only after both exact origins exist and retains them through settlement", order => { + const f = withProjectedArrivalOrder(provider, order), pending = f.pending(); + expect(f.nativeStageOrder()).toEqual(["tool", "permission_requested"]); + expect(pending.schema).toBe("paperclip.e2e.native-active-stop-pending.v2"); + expect(pending.toolOriginRowSha256).toMatch(/^sha256:[a-f0-9]{64}$/u); + expect(pending.toolStartedRowSha256).toMatch(/^sha256:[a-f0-9]{64}$/u); + expect(pending.toolStartedSourceSeq < pending.requestSourceSeq).toBe(order === "tool-first"); + f.settle(); + expect(readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() })) + .toMatchObject({ branch: "pending_permission_cancelled", pending }); + }); + it("cannot retain permission-only projector output before its exact origin arrives", () => { + const f = withProjectedArrivalOrder(provider, "permission-first"); + expect(() => observeActiveStopPending({ ...f.state(), events: f.beforeTool!, scope: f.scope, caller, cancellationRequestId })).toThrow(); + }); + it.each(arrivalOrders)("rejects a delivered answer in real-projector %s evidence", order => { + const f = withProjectedArrivalOrder(provider, order); f.delivered("cancel"); expect(f.pending).toThrow("permission already answered"); + }); + it("does not accept a permission-first request for a different native origin", () => { + const f = withProjectedArrivalOrder(provider, "permission-first"); + const tool = f.events.find(row => row.eventType === "provider.notice.recorded" && payload(row).provenance.eventType === "tool")!; + payload(tool).details.find((d: Row) => d.name === "toolCallId").value = "another-tool"; + expect(f.pending).toThrow("native operation origin missing"); + }); + it.each([ + ["missing native origin", (f: ReturnType) => { f.events.splice(1, 1); }], + ["missing canonical start", (f: ReturnType) => { f.events.splice(2, 1); }], + ["wrong native tool", (f: ReturnType) => { payload(f.events[1]!).details.find((d: Row) => d.name === "toolCallId").value = "other"; }], + ["wrong canonical tool", (f: ReturnType) => { payload(f.events[2]!).executionId = "other"; }], + ["wrong command or path", (f: ReturnType) => { payload(f.events[1]!).details.find((d: Row) => d.name === (provider === "cursor" ? "commandSha256" : "target")).value = provider === "cursor" ? `sha256:${"b".repeat(64)}` : "other.txt"; }], + ["wrong source", (f: ReturnType) => { frame(f.events[2]!).sourceInstanceId = "other"; frame(f.events[2]!).sourceEventId = "other:run:3"; }], + ["wrong turn", (f: ReturnType) => { frame(f.events[2]!).turnId = "other"; }], + ["wrong request", (f: ReturnType) => { payload(f.events[0]!).request.requestId = "other"; }], + ["replayed canonical start", (f: ReturnType) => { f.events.push(f.row(5, "tool.execution.started", payload(f.events[2]!))); }], + ["replayed native origin", (f: ReturnType) => { f.events.push(f.row(5, "provider.notice.recorded", payload(f.events[1]!))); }], + ["answered permission", (f: ReturnType) => { f.events.push(f.notice(5, "permission_delivered", { requestId: "request", outcome: "cancel" })); }], + ["terminal operation", (f: ReturnType) => { f.events.push(f.row(5, "tool.execution.completed", { schema: "paperclip.tool.execution.v1", transport: "builtin", executionId: "tool", status: "failed" })); }], + ] as const)("rejects permission-first %s before Stop", (_label, mutate) => { + const f = withArrivalOrder(provider, "permission-first"); mutate(f); expect(f.pending).toThrow(); + }); + it("cannot substitute a canonical start first observed after Stop", () => { + const f = withArrivalOrder(provider, "permission-first"), pending = f.pending(); + const started = f.events.splice(2, 1)[0]!; + // A late row has a new durable identity even when every operation field + // is identical. Final evidence must not backfill the retained receipt. + started.seq = frame(started).sourceSeq = 5; frame(started).sourceEventId = "source:run:5"; + f.events.push(started); f.settle(); + for (const row of f.events.slice(-2)) { + row.seq += 1; frame(row).sourceSeq += 1; frame(row).sourceEventId = `source:run:${row.seq}`; + } + expect(() => readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() })) + .toThrow("observed pending request changed"); + }); + }); + it("still rejects a Cursor permission notice before the origin its projector requires", () => { + const f = withProjectedArrivalOrder("cursor", "permission-first"); + [f.events[1], f.events[2]] = [f.events[2]!, f.events[1]!]; + f.events.forEach((row, index) => { + row.seq = frame(row).sourceSeq = index + 1; frame(row).sourceEventId = `source:run:${index + 1}`; + }); + expect(f.pending).toThrow("duplicate native operation lifecycle"); + }); + it.each(["cursor"] as const)("accepts the mixed v1/v2 session prefix before strict %s pending proof", provider => { + const f = withSessionPrefix(provider); + expect(f.pending()).toMatchObject({ requestId: "request", toolCallId: "tool", permissionSourceSeq: 6, requestSourceSeq: 7 }); + }); + it.each([ + ["schema/version mismatch", (f: ReturnType) => { frame(f.events[1]!).schemaVersion = 1; }], + ["row/envelope mismatch", (f: ReturnType) => { f.events[1]!.protocolSchemaVersion = 1; }], + ["unknown schema", (f: ReturnType) => { frame(f.events[1]!).schema = "paperclip.prp.event.v3"; frame(f.events[1]!).schemaVersion = f.events[1]!.protocolSchemaVersion = 3; }], + ["foreign session source", (f: ReturnType) => { frame(f.events[1]!).sourceKind = "provider"; }], + ] as const)("still rejects %s in a mixed-version stream", (_label, mutate) => { + const f = withSessionPrefix(); mutate(f); expect(f.pending).toThrow("invalid/duplicate/foreign canonical row"); + }); + it("does not let a valid v2 prefix hide the retained Cursor incomplete-evidence failure", () => { + const f = withSessionPrefix(); + const notice = f.events.find(row => row.eventType === "provider.notice.recorded")!; + payload(notice).provenance.eventType = "evidence_incomplete"; + payload(notice).details = Object.entries({ stage: "evidence_incomplete", toolCallId: "unavailable", reason: "projection_failed" }).map(([name, value]) => ({ name, value })); + expect(f.pending).toThrow("Cursor evidence is explicitly incomplete"); + }); + it("still requires the original native tool ID on the durable card after a valid v2 prefix", () => { + const f = withSessionPrefix(); + delete payload(f.events.find(row => row.eventType === "runtime_request.created")!).request.details.toolCallId; + expect(f.pending).toThrow("native notice/card identity mismatch"); + }); + it.each(["cursor"] as const)("binds %s's unanswered callback to cancelled provider settlement and caller-owned Stop", provider => { + const f = fixture(provider), pending = f.pending(); f.settle(); + expect(f.permissionResponse.mock.calls).toEqual([[{ action: "cancel" }]]); + expect(readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() })).toMatchObject({ + schema: "paperclip.e2e.native-active-stop-settlement.v2", branch: "pending_permission_cancelled", normalCompletionAccepted: false, taskStillInProgress: true, replayAllowed: false, + pending: { normalizedSessionId: "normalized-session", nativeSessionId: "native-session", cancellationRequestId }, + }); + }); + it("matches the retained Product closure shape with distinct native and item identities", () => { + const { f, pending, read } = settled(); + expect(pending.toolCallId).toBe("tool"); + // Content-free shape captured from the failed Product attempt. These exact + // payloads come from mapTerminalTurn -> cancelPendingRequests above. + expect(payload(f.events[4]!)).toEqual({ requestId: "request", requestKind: "permission_approval", + turnId: "turn", itemId: "item-run", reason: "turn_terminal" }); + expect(payload(f.events[5]!)).toEqual({ status: "cancelled", error: null }); + expect(read().schema).toBe("paperclip.e2e.native-active-stop-settlement.v2"); + }); + it("admits only a fully attested remote bootstrap read completed before the pending operation", () => { + const f = fixture(), actionFile = `.paperclip-eval-action-${"a".repeat(36)}.txt`; + const readTargetSha256 = `sha256:${createHash("sha256").update(actionFile).digest("hex")}`; + for (const row of f.events) { row.seq += 4; frame(row).sourceSeq += 4; frame(row).sourceEventId = `source:run:${row.seq}`; } + f.events.unshift(f.notice(1, "tool", { toolCallId: "bootstrap", operation: "read", status: "pending", readTargetSha256 }), + f.row(2, "tool.execution.started", { schema: "paperclip.tool.execution.v1", executionId: "bootstrap", transport: "builtin", status: "running", operation: "read", target: actionFile }), + f.notice(3, "tool", { toolCallId: "bootstrap", operation: "read", status: "completed", readTargetSha256 }), + f.row(4, "tool.execution.completed", { schema: "paperclip.tool.execution.v1", executionId: "bootstrap", transport: "builtin", status: "completed", operation: "read", target: actionFile })); + const bootstrap = { actionFile, events: f.events }; + expect(() => observeActiveStopPending({ ...f.state(), scope: f.scope, caller, cancellationRequestId, bootstrap })).not.toThrow(); + payload(f.events[2]!).details.find((d: Row) => d.name === "readTargetSha256").value = `sha256:${"0".repeat(64)}`; + expect(() => observeActiveStopPending({ ...f.state(), scope: f.scope, caller, cancellationRequestId, bootstrap })).toThrow(/bootstrap/); + }); + it("does not compare remote wall clock or transaction timestamps to the operator clock", () => { + const f = fixture(); for (const row of f.events) { frame(row).emittedAt = "2099-01-01T00:00:00Z"; row.createdAt = "1900-01-01T00:00:00Z"; } + const pending = f.pending(); f.settle(); + expect(() => readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() })).not.toThrow(); + }); + it.each([ + ["prior Stop claim", (f: ReturnType) => { f.run.resultJson.startupCancellation = { cancellationRequestId }; }], + ["answered callback", (f: ReturnType) => { f.events.push(f.notice(5, "permission_delivered", { requestId: "request", outcome: "cancel" })); }], + ["already settled provider", (f: ReturnType) => { f.events.push(f.row(5, "turn.completed", { status: "completed" })); }], + ["already closed request", (f: ReturnType) => { f.events.push(f.row(5, "runtime_request.resolved", {})); }], + ["failed operation", (f: ReturnType) => { f.events.push(f.row(5, "tool.execution.completed", { schema: "paperclip.tool.execution.v1", transport: "builtin", executionId: "tool", status: "failed" })); }], + ] as const)("refuses %s before dispatch", (_label, mutate) => { const f = fixture(); mutate(f); expect(f.pending).toThrow(); }); + it.each([ + ["completed terminal", ({ f }: ReturnType) => { f.events[5]!.eventType = frame(f.events[5]!).eventType = "turn.completed"; payload(f.events[5]!).status = "completed"; }], + ["interrupted terminal", ({ f }: ReturnType) => { f.events[5]!.eventType = frame(f.events[5]!).eventType = "turn.interrupted"; payload(f.events[5]!).status = "interrupted"; }], + ["failed terminal", ({ f }: ReturnType) => { payload(f.events[5]!).error = { code: "failure" }; }], + ["duplicate terminal", ({ f }: ReturnType) => { f.events.push(f.row(7, "turn.cancelled", payload(f.events[5]!))); }], + ["late same-tool replay", ({ f }: ReturnType) => { f.events.push(f.notice(7, "tool", { status: "in_progress" })); }], + ["wrong closed item", ({ f }: ReturnType) => { payload(f.events[4]!).itemId = "other"; }], + ["reordered source stream", ({ f }: ReturnType) => { frame(f.events[5]!).sourceSeq = 1; frame(f.events[5]!).sourceEventId = "source:run:1"; }], + ["missing terminal", ({ f }: ReturnType) => { f.events.pop(); }], + ["wrapper type mismatch", ({ f }: ReturnType) => { f.events[5]!.eventType = "turn.completed"; }], + ["foreign normalized session", ({ f }: ReturnType) => { frame(f.events[5]!).normalizedSessionId = "native-session"; }], + ["foreign source", ({ f }: ReturnType) => { frame(f.events[5]!).sourceInstanceId = "foreign"; }], + ["foreign turn", ({ f }: ReturnType) => { frame(f.events[5]!).turnId = "other-turn"; }], + ["foreign company", ({ f }: ReturnType) => { f.events[5]!.companyId = "other"; }], + ["foreign request", ({ f }: ReturnType) => { payload(f.events[4]!).requestId = "other"; }], + ["expired rather than cancelled", ({ f }: ReturnType) => { f.events[4]!.eventType = frame(f.events[4]!).eventType = "runtime_request.expired"; }], + ["provider death", ({ f }: ReturnType) => { payload(f.events[4]!).reason = "provider_process_lost"; }], + ["raw backend closure is not the Product contract", ({ f }: ReturnType) => { payload(f.events[4]!).reason = "explicit_cancellation"; }], + ["answered closure", ({ f }: ReturnType) => { payload(f.events[4]!).action = "accept"; }], + ["resolved request", ({ f }: ReturnType) => { f.events[4]!.eventType = frame(f.events[4]!).eventType = "runtime_request.resolved"; }], + ["duplicate closure", ({ f }: ReturnType) => { f.events.push(f.row(7, "runtime_request.cancelled", payload(f.events[4]!))); }], + ["foreign closure turn", ({ f }: ReturnType) => { payload(f.events[4]!).turnId = "other"; }], + ["missing acknowledgement", ({ f }: ReturnType) => { delete f.run.resultJson.nativeCancellation.acknowledgementAuditId; }], + ["unacknowledged intent", ({ f }: ReturnType) => { f.run.resultJson.nativeCancellation.dispatchState = "pending"; }], + ["replay permitted", ({ f }: ReturnType) => { payload(f.events[4]!).replayAllowed = true; }], + ["tampered pending row", ({ f }: ReturnType) => { payload(f.events[3]!).request.itemId = "changed"; }], + ["missing pending receipt", (s: ReturnType) => { s.pending.requestRowSha256 = ""; }], + ["legacy v1 pending receipt", (s: ReturnType) => { (s.pending as Row).schema = "paperclip.e2e.native-active-stop-pending.v1"; }], + ["missing native origin binding", (s: ReturnType) => { delete (s.pending as Row).toolOriginRowSha256; }], + ["missing tool start binding", (s: ReturnType) => { delete (s.pending as Row).toolStartedRowSha256; }], + ["changed native origin", ({ f }: ReturnType) => { frame(f.events[0]!).emittedAt = "2026-01-01T00:00:01Z"; }], + ["changed tool start", ({ f }: ReturnType) => { frame(f.events[1]!).emittedAt = "2026-01-01T00:00:01Z"; }], + ["wrong origin sequence", (s: ReturnType) => { s.pending.toolOriginSourceSeq += 1; }], + ["wrong start sequence", (s: ReturnType) => { s.pending.toolStartedSourceSeq += 1; }], + ["missing caller", ({ f }: ReturnType) => { delete f.run.resultJson.startupCancellation.requestedBy; }], + ["null caller", ({ f }: ReturnType) => { f.run.resultJson.startupCancellation.requestedBy.userId = null; }], + ["foreign caller", ({ f }: ReturnType) => { f.run.resultJson.startupCancellation.requestedBy.userId = "another-board-user"; }], + ["agent caller", ({ f }: ReturnType) => { f.run.resultJson.startupCancellation.requestedBy.type = "agent"; }], + ["unbound caller receipt", (s: ReturnType) => { (s.pending as Row).caller = null; }], + ["foreign intent", ({ f }: ReturnType) => { f.run.resultJson.nativeCancellation.intentId = "native-cancellation:other"; }], + ["foreign ack scope", ({ f }: ReturnType) => { f.run.resultJson.nativeCancellation.companyId = "other"; }], + ["undispatched cancellation", ({ f }: ReturnType) => { f.run.resultJson.nativeCancellation.dispatched = false; }], + ["same audit twice", ({ f }: ReturnType) => { f.run.resultJson.nativeCancellation.acknowledgementAuditId = "audit-intent"; }], + ["finished task", ({ f }: ReturnType) => { f.issue.status = "done"; }], + ["extra semantic operation", ({ f }: ReturnType) => { f.events.push(f.row(7, "tool.execution.started", { executionId: "other" })); }], + ["extra native operation", ({ f }: ReturnType) => { f.events.push(f.notice(7, "tool", { toolCallId: "other", status: "pending" })); }], + ["native session switched", ({ f }: ReturnType) => { payload(f.events[0]!).provenance.sessionId = "other"; }], + ["target operation completed", ({ f }: ReturnType) => { f.events.push(f.row(7, "tool.execution.completed", { schema: "paperclip.tool.execution.v1", transport: "builtin", executionId: "tool", status: "completed" })); }], + ] as const)("rejects %s", (_label, mutate) => { const s = settled(); mutate(s); expect(s.read).toThrow(); }); + it.each(["closure", "terminal"] as const)("rejects missing/null envelope turn and foreign null-turn stream on %s", kind => { + for (const turn of [null, undefined]) for (const mismatch of ["none", "session", "source"]) { + const s = settled(), event = frame(s.f.events[kind === "closure" ? 4 : 5]!); + event.turnId = turn; + if (mismatch === "session") event.normalizedSessionId = "foreign-session"; + if (mismatch === "source") { event.sourceInstanceId = "foreign-source"; event.sourceEventId = `foreign-source:run:${event.sourceSeq}`; } + expect(s.read).toThrow(/pending callback/); + } + }); + it.each([ + [{ deploymentMode: "authenticated" }, { session: { userId: "local-board", id: "paperclip:local_implicit:local-board" } }], + [{ deploymentMode: "local_trusted" }, { session: { userId: null, id: "paperclip:local_implicit:local-board" } }], + [{ deploymentMode: "local_trusted" }, { session: { userId: "another", id: "paperclip:local_implicit:another" } }], + [{ deploymentMode: "local_trusted" }, { session: { userId: "local-board", id: "paperclip:session:local-board" } }], + ])("rejects a non-fixture authentication context", (health, session) => expect(() => readActiveStopCaller(health, session)).toThrow()); + it("rejects a Stop dispatch not causally after the pending observation", () => { + const { f, pending } = settled(); + expect(() => readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: pending.observedMonotonicNs })).toThrow(/pre-dispatch/); + }); +}); + +describe("active Stop flow wiring", () => { + it.each([ + ["cursor", "tool-first"], ["cursor", "permission-first"], + ] as const)("awaits retention and rechecks real %s %s evidence before issuing the single caller UUID request", async (provider, arrivalOrder) => { + const f = withProjectedArrivalOrder(provider, arrivalOrder); const order: string[] = []; let retainedId = ""; + const stop = vi.fn(async (runId: string, id: string) => { expect(runId).toBe("run"); expect(id).toBe(retainedId); order.push("stop"); return f.settle(id); }); + const result = await stopAtPendingPermission({ scope: f.scope, caller, deadlineAt: Date.now() + 1000, + load: async () => { order.push("load"); return f.state(); }, + retain: async receipt => { order.push("retain-start"); await Promise.resolve(); retainedId = receipt.cancellationRequestId; order.push("retain-done"); }, stop }); + expect(order).toEqual(["load", "retain-start", "retain-done", "load", "stop", "load"]); + expect(stop).toHaveBeenCalledTimes(1); expect(result.settlement.branch).toBe("pending_permission_cancelled"); + }); + it.each(["retention failed", "competing Stop", "answered after capture"])("never sends Stop when %s", async mode => { + const f = fixture(), stop = vi.fn(); + await expect(stopAtPendingPermission({ scope: f.scope, caller, deadlineAt: Date.now() + 1000, load: async () => f.state(), stop, + retain: async () => { if (mode === "retention failed") throw Error("disk failure"); if (mode === "competing Stop") f.run.resultJson.startupCancellation = { cancellationRequestId: "foreign" }; else f.events.push(f.row(5, "runtime_request.resolved", {})); }, + })).rejects.toThrow(); expect(stop).not.toHaveBeenCalled(); + }); + it.each(["native origin", "canonical start"])("never sends Stop when the retained %s changes before the fresh reread", async kind => { + const f = withArrivalOrder("cursor", "permission-first"), stop = vi.fn(); + await expect(stopAtPendingPermission({ scope: f.scope, caller, deadlineAt: Date.now() + 1000, load: async () => f.state(), stop, + retain: async () => { frame(f.events[kind === "native origin" ? 1 : 2]!).emittedAt = "2026-01-01T00:00:01Z"; }, + })).rejects.toThrow("pending boundary changed or expired"); + expect(stop).not.toHaveBeenCalled(); + }); + it("never sends Stop to make a missing tool start arrive", async () => { + const f = withArrivalOrder("cursor", "permission-first"); + const start = f.events.splice(2, 1)[0]!, retain = vi.fn(); + const stop = vi.fn(async () => { f.events.push(start); return f.settle(); }); + await expect(stopAtPendingPermission({ scope: f.scope, caller, deadlineAt: Date.now() + 1000, load: async () => f.state(), retain, stop })) + .rejects.toThrow("duplicate canonical operation lifecycle"); + expect(retain).not.toHaveBeenCalled(); expect(stop).not.toHaveBeenCalled(); + }); + it("fails promptly on a definitive provider failure before calling a throwing evidence reader", async () => { + const f = fixture(); const started = Date.now(); + await expect(stopAtPendingPermission({ scope: f.scope, caller, deadlineAt: started + 10000, load: async () => f.state(), retain: async () => {}, stop: async (_run, id) => { + f.settle(id); f.run.status = "failed"; f.events.length = 0; return f.run; + } })).rejects.toThrow(/Stopped waiting.*unexpected run terminal/); + expect(Date.now() - started).toBeLessThan(500); + }); +}); + +describe("explicit active Stop discovery", () => { + it("adds exactly two versioned cells without enabling them in --all", () => { + const suite = runnerSuites.find(s => s.id === "native-active-stop")!; + const cells = runnerMatrix.filter(e => e.suite === suite); + expect(cells).toHaveLength(2); expect(suite.manualOnly).toBe(true); + expect(cells.map(e => `${e.profile.qualificationCandidate}/${e.environment.id}`).sort()).toEqual(["cursor/daytona", "cursor/local"]); + expect(cells.every(e => e.task.expectedRunCount === 1 && e.task.flow === "native_active_stop" && e.task.expectedTerminalState?.run === "cancelled")).toBe(true); + expect(suite.definitionMetadata).toMatchObject({ version: 4, evidence: "paperclip.e2e.native-active-stop-settlement.v2", normalCompletionAccepted: false, providerDeath: "not-covered" }); + expect(suiteDefinitionHash(suite)).not.toBe(suiteDefinitionHash({ ...suite, definitionMetadata: { ...suite.definitionMetadata, version: 3 } })); + expect(suiteDefinitionHash(suite)).not.toBe(suiteDefinitionHash({ ...suite, definitionMetadata: { ...suite.definitionMetadata, normalCompletionAccepted: true } })); + expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(e => e.suite.id === suite.id)).toBe(false); + }); +}); + + +describe("active Stop cleanup remains mandatory", () => { + const proof = () => ({ environment: "local" as const, completed: true, identityChanged: false, processes: { captured: true, live: [] as number[] }, + watcher: { complete: true, targetMutationCount: 0 }, samples: ["before-request", "pending", "after-stop", "after-cleanup"].map(phase => ({ phase, absent: true })) }); + it("requires all causal samples plus the continuous watcher and retired owned identities", () => expect(() => assertActiveStopRetirement(proof())).not.toThrow()); + it.each([ + ["live child", (p: ReturnType) => { p.processes.live.push(42); }], + ["unobserved process", (p: ReturnType) => { p.processes.captured = false; }], + ["replaced root", (p: ReturnType) => { p.identityChanged = true; }], + ["incomplete watch", (p: ReturnType) => { p.watcher.complete = false; }], + ["transient write", (p: ReturnType) => { p.watcher.targetMutationCount = 1; }], + ["written target", (p: ReturnType) => { p.samples[2]!.absent = false; }], + ["missing final sample", (p: ReturnType) => { p.samples.pop(); }], + ["missing settlement", (p: ReturnType) => { p.completed = false; }], + ] as const)("rejects %s even after an apparent cancelled run", (_label, mutate) => { const p = proof(); mutate(p); expect(() => assertActiveStopRetirement(p)).toThrow(); }); +}); + +function remoteRetirementProof() { + const root = { pid: 50, ppid: 1, startTicks: "200", bootId: "12345678-1234-1234-1234-123456789abc" }; + const child = { ...root, pid: 51, ppid: 50, startTicks: "201" }; + const snapshot = (sequence: number, live: number[]) => ({ + binding: { companyId: "company", environmentId: "env", runId: "run", leaseId: "lease", sandboxId: "sandbox", image: `image@sha256:${"a".repeat(64)}`, remoteCwd: "/home/daytona/workspace" }, + observedAtMs: sequence, receivedAtMs: sequence + 1, observedMonotonicNs: String(sequence), complete: true, workspace: {}, + targets: { "target.txt": { absent: true, sha256: null, complete: true, mutationCount: 0, parent: { dev: "1", ino: "2" } } }, + watcher: { complete: true, targetMutationCount: 0, workspaceMutationCount: 0 }, + processes: { captured: true, root, journal: [root, child], live }, + setup: { path: ".action.txt", sha256: sequence === 1 ? null : `sha256:${"b".repeat(64)}`, published: sequence !== 1 }, attached: null, + }); + const observations: ActiveStopRemoteObservation[] = [ + { phase: "before-request", source: "live-snapshot", snapshot: snapshot(1, [50, 51]) }, + { phase: "pending", source: "live-snapshot", snapshot: snapshot(2, [50, 51]) }, + { phase: "owned-process-retirement", source: "retirement-seal", snapshot: snapshot(3, []) }, + ]; + return { scope: { companyId: "company", runId: "run", target: "target.txt" }, observations }; +} +describe("Daytona active Stop observation lifetime", () => { + it("binds one continuous owned-tree retirement seal without claiming a later filesystem observation", () => { + const remote = remoteRetirementProof(), terminal = remote.observations[2]!.snapshot; + expect(assertActiveStopRetirement({ environment: "daytona", completed: true, identityChanged: false, + processes: terminal.processes, watcher: terminal.watcher, remote })).toMatchObject({ + schema: "paperclip.e2e.native-active-stop-remote-retirement.v1", coverage: "continuous-through-owned-process-retirement", + filesystemAfterRetirementObserved: false, observations: [ + { phase: "before-request", source: "live-snapshot" }, { phase: "pending", source: "live-snapshot" }, + { phase: "owned-process-retirement", source: "retirement-seal" }, + ], + }); + }); + it.each([ + ["seal relabeled fresh", (p: ReturnType) => { p.observations[2]!.source = "live-snapshot"; }], + ["seal replayed as pending", (p: ReturnType) => { p.observations[1]!.snapshot = p.observations[2]!.snapshot; }], + ["after-UI seal replay", (p: ReturnType) => { p.observations.push({ ...p.observations[2]!, phase: "after-cleanup" as any }); }], + ["missing pending", (p: ReturnType) => { p.observations.splice(1, 1); }], + ["all foreign run snapshots", (p: ReturnType) => { for (const o of p.observations) o.snapshot.binding.runId = "other"; }], + ["all foreign company snapshots", (p: ReturnType) => { for (const o of p.observations) o.snapshot.binding.companyId = "other"; }], + ["foreign lease", (p: ReturnType) => { p.observations[2]!.snapshot.binding.leaseId = "other"; }], + ["foreign run", (p: ReturnType) => { p.observations[1]!.snapshot.binding.runId = "other"; }], + ["replacement root", (p: ReturnType) => { p.observations[2]!.snapshot.processes.root = { ...p.observations[2]!.snapshot.processes.root!, startTicks: "300" }; }], + ["live child", (p: ReturnType) => { p.observations[2]!.snapshot.processes.live = [51]; }], + ["lost child journal", (p: ReturnType) => { p.observations[2]!.snapshot.processes.journal.pop(); }], + ["unobserved pending root", (p: ReturnType) => { p.observations[1]!.snapshot.processes.live = []; }], + ["transient target mutation", (p: ReturnType) => { p.observations[2]!.snapshot.watcher.targetMutationCount = 1; }], + ["workspace mutation", (p: ReturnType) => { p.observations[2]!.snapshot.watcher.workspaceMutationCount = 1; }], + ["late target present", (p: ReturnType) => { p.observations[2]!.snapshot.targets["target.txt"]!.absent = false; }], + ["incomplete watcher", (p: ReturnType) => { p.observations[2]!.snapshot.watcher.complete = false; }], + ["changed action", (p: ReturnType) => { p.observations[2]!.snapshot.setup.sha256 = `sha256:${"c".repeat(64)}`; }], + ] as const)("rejects %s", (_label, mutate) => { + const proof = remoteRetirementProof(); mutate(proof); expect(() => readActiveStopRemoteRetirement(proof)).toThrow(); + }); +}); diff --git a/tests/runner-e2e/native-bootstrap-read-proof.test.ts b/tests/runner-e2e/native-bootstrap-read-proof.test.ts new file mode 100644 index 00000000000..ac87aa1bfdc --- /dev/null +++ b/tests/runner-e2e/native-bootstrap-read-proof.test.ts @@ -0,0 +1,115 @@ +import { createHash } from "node:crypto"; +import { expect, it } from "vitest"; +import { bootstrapReadExecutionId, withoutProvenBootstrapReads, type BootstrapReadNotice } from "./native-bootstrap-read-proof.js"; +const actionFile = `.paperclip-eval-action-${"a".repeat(36)}.txt`; +const origin: BootstrapReadNotice = { runId: "run", sessionId: "session", turnId: "turn", toolCallId: "tested", stage: "tool", operation: "execute", status: "pending", seq: 20 }; +function read(status = "completed", seq = 1): BootstrapReadNotice { return { ...origin, toolCallId: "bootstrap", operation: "read", readTargetSha256: `sha256:${createHash("sha256").update(actionFile).digest("hex")}`, status, seq }; } +function receipt(type = "completed", status = "completed", seq = 2) { + return { runId: "run", seq, protocolSchemaVersion: 1, eventType: `tool.execution.${type}`, payload: { prpEvent: { + schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", runId: "run", turnId: "turn", eventType: `tool.execution.${type}`, emittedAt: new Date(seq).toISOString(), + payload: { schema: "paperclip.tool.execution.v1", executionId: "bootstrap", transport: "builtin", operation: "read", target: actionFile, status }, + } } }; +} +it.each(["completed", "failed"])("accepts an exact %s read even when the first native event is terminal", status => { + expect(withoutProvenBootstrapReads([read(status), origin], origin, { actionFile, events: [receipt("completed", status)] })).toEqual([origin]); +}); +it("accepts a complete started/progressed/failed lifecycle, including failed unpublished-file reads", () => { + const rows = [read("pending", 1), read("in_progress", 3), read("failed", 5), origin]; + const events = [receipt("started", "running", 2), receipt("progressed", "running", 4), receipt("completed", "failed", 6)]; + expect(withoutProvenBootstrapReads(rows, origin, { actionFile, events })).toEqual([origin]); +}); +it.each([ + "wrong-target", "missing-target", "foreign-run", "foreign-turn", "foreign-row-run", "wrong-source", "wrong-schema", "wrong-payload", "wrong-transport", "wrong-operation", "wrong-event-type", "invalid-date", "duplicate-row", "duplicate-seq", "conflicting-target", "missing-row", "missing-terminal", "late-row", "unknown-read-origin", +])("rejects %s durable proof", variant => { + const row = receipt(), rows = [row]; const notices = [read(), origin]; const event = row.payload.prpEvent; + if (variant === "wrong-target") event.payload.target = "private.txt"; + if (variant === "missing-target") delete (event.payload as any).target; + if (variant === "foreign-run") event.runId = "other"; + if (variant === "foreign-row-run") row.runId = "other"; + if (variant === "foreign-turn") event.turnId = "other"; + if (variant === "wrong-source") event.sourceKind = "provider"; + if (variant === "wrong-schema") event.schemaVersion = 2; + if (variant === "wrong-payload") event.payload.schema = "unknown"; + if (variant === "wrong-transport") event.payload.transport = "mcp"; + if (variant === "wrong-operation") event.payload.operation = "edit"; + if (variant === "wrong-event-type") event.eventType = "tool.execution.progressed"; + if (variant === "invalid-date") event.emittedAt = "unknown"; + if (variant === "duplicate-row") rows.push(structuredClone(row)); + if (variant === "duplicate-seq") row.seq = 1; + if (variant === "conflicting-target") { const other = receipt("completed", "completed", 3); other.payload.prpEvent.payload.target = "foreign.txt"; rows.push(other); } + if (variant === "missing-row") rows.length = 0; + if (variant === "missing-terminal") { rows[0] = receipt("started", "running"); notices[0] = read("pending"); } + if (variant === "late-row") row.seq = 21; + if (variant === "unknown-read-origin") event.payload.executionId = "unobserved-native-origin"; + expect(() => withoutProvenBootstrapReads(notices, origin, { actionFile, events: rows })).toThrow(/proof/); +}); +it("does not exempt unrelated earlier reads or infer a path from a title", () => { + const unrelated = { ...read(), toolCallId: "private-read", seq: 3 }; + const wrong = receipt("completed", "completed", 4); wrong.payload.prpEvent.payload.executionId = "private-read"; wrong.payload.prpEvent.payload.target = "secrets.txt"; + Object.assign(wrong.payload.prpEvent.payload, { name: `Read ${actionFile}` }); + expect(() => withoutProvenBootstrapReads([read(), unrelated, origin], origin, { actionFile, events: [receipt(), wrong] })).toThrow(); + expect(withoutProvenBootstrapReads([read(), unrelated, origin], origin)).toHaveLength(3); +}); +it("rejects reused native IDs, cross-session notices and ambiguous completion ordering", () => { + for (const notices of [[read(), read("completed", 3), origin], [{ ...read(), sessionId: "other" }, origin], [read(), { ...read(), seq: 30 }, origin]]) { + expect(() => withoutProvenBootstrapReads(notices, origin, { actionFile, events: [receipt()] })).toThrow(); + } + expect(() => withoutProvenBootstrapReads([read(), origin], origin, { actionFile: "guessed-nonce.txt", events: [receipt()] })).toThrow(); +}); +it("preserves later reads and unknown native kinds for the caller's extra-operation rejection", () => { + for (const n of [{ ...read(), seq: 21 }, { ...read(), operation: undefined }, { ...read(), operation: "edit" }]) { + expect(withoutProvenBootstrapReads([n, origin], origin, { actionFile, events: [] })).toEqual([n, origin]); + } +}); +it("mirrors stable ASCII bounds and rejects invalid native identities", () => { + expect(bootstrapReadExecutionId("read:alpha-1")).toBe("read:alpha-1"); + expect(bootstrapReadExecutionId("a".repeat(160))).toBe("a".repeat(160)); + expect(bootstrapReadExecutionId("a".repeat(161))).toBe("acpx-tool-c4b0cf58ad5787f270b446a5eb28905e09d8e0aa06e7f577e27aed951d573406"); + expect(bootstrapReadExecutionId("tool / é")).toBe("acpx-tool-c870bde596691facb0dc508859665ab3ee9de278e7d49adecaab0fa600feaff0"); + expect(bootstrapReadExecutionId("é".repeat(121))).toBe("acpx-tool-9e1e0f61749be86786959ac0384b636ca4b9e00c78e9d77161f0432871edf151"); + for (const id of ["", "a".repeat(241), "read\nnext", "[REDACTED]"]) expect(() => bootstrapReadExecutionId(id)).toThrow(); +}); + +it("accepts omitted terminal path/kind only with the retained single-path origin attestation", () => { + const started = receipt("started", "running", 2), completed = receipt("completed", "failed", 4); + Object.assign(completed.payload.prpEvent.payload, { target: null, operation: "unknown" }); + const notices = [read("pending", 1), read("failed", 3), origin], proof = { actionFile, events: [started, completed] }; + expect(withoutProvenBootstrapReads(notices, origin, proof)).toEqual([origin]); + for (const digest of [undefined, `sha256:${"0".repeat(64)}`]) { + expect(() => withoutProvenBootstrapReads([{ ...notices[0]!, readTargetSha256: digest }, ...notices.slice(1)], origin, proof)).toThrow(); + expect(() => withoutProvenBootstrapReads([notices[0]!, { ...notices[1]!, readTargetSha256: digest }, origin], origin, proof)).toThrow(); + } +}); + +it.each(["cursor"] as const)("correlates actual %s passive notices through the public reader and canonical omitted-field updates", async provider => { + const { createCursorToolEvidence } = await import("../../packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.js"); + const { readCursorToolEvidence } = await import("./cursor-native-evidence.js"); + const create = createCursorToolEvidence; + for (const variant of ["exact", "wrong-path", "multi-path", "ambiguous-update"]) { + const rows: any[] = []; + const projector = create({ sessionId: "session", turnId: "turn", workingDirectory: "/workspace", active: () => true, + emit: event => rows.push({ runId: "run", seq: rows.length * 2 + 1, protocolSchemaVersion: 1, eventType: event.eventType, payload: { prpEvent: { + schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", runId: "run", turnId: "turn", emittedAt: new Date(1).toISOString(), eventType: event.eventType, payload: event.payload, + } } }), + }); + const path = variant === "wrong-path" ? "private.txt" : actionFile; + projector.tool({ type: "tool_call", tag: "tool_call", toolCallId: "bootstrap", kind: "read", status: "pending", rawInput: variant === "multi-path" ? { paths: [path, "private.txt"] } : { path }, locations: [{ path }] }); + projector.tool({ type: "tool_call", tag: "tool_call_update", toolCallId: "bootstrap", status: "failed", ...(variant === "ambiguous-update" ? { rawInput: { path, paths: [path, "private.txt"] } } : {}) }); + const evaluate = () => { + const readNotices = readCursorToolEvidence(rows, "run"); + const started = receipt("started", "running", 2), end = receipt("completed", "failed", 4); + Object.assign(end.payload.prpEvent.payload, { target: null, operation: "unknown" }); + return withoutProvenBootstrapReads([...readNotices, origin], origin, { actionFile, events: [...rows, started, end] }); + }; + if (variant === "exact") expect(evaluate()).toEqual([origin]); else expect(evaluate).toThrow(); + } +}); + +it("accepts bounded correlated progress for status-omitting native content updates", () => { + const notices = [read("pending", 1), read("completed", 5), origin]; + const progressed = receipt("progressed", "running", 3); Object.assign(progressed.payload.prpEvent.payload, { target: null, operation: "unknown" }); + const events = [receipt("started", "running", 2), progressed, receipt("completed", "completed", 6)]; + expect(withoutProvenBootstrapReads(notices, origin, { actionFile, events })).toEqual([origin]); + progressed.payload.prpEvent.payload.target = "other.txt"; + expect(() => withoutProvenBootstrapReads(notices, origin, { actionFile, events })).toThrow(); +}); diff --git a/tests/runner-e2e/native-bootstrap-read-proof.ts b/tests/runner-e2e/native-bootstrap-read-proof.ts new file mode 100644 index 00000000000..758700096f6 --- /dev/null +++ b/tests/runner-e2e/native-bootstrap-read-proof.ts @@ -0,0 +1,93 @@ +import { createHash } from "node:crypto"; + +export interface BootstrapReadNotice { + runId: string; sessionId: string; turnId: string; toolCallId: string; seq: number; + stage: string; operation?: string; status?: string; readTargetSha256?: string; +} +export interface BootstrapReadProof { actionFile: string; events: readonly unknown[] } +const record = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +const hashId = (domain: string, id: string) => `acpx-tool-${createHash("sha256").update(domain).update("tool\0").update(id).digest("hex")}`; +/** These native Product fixtures require runnerd (harness-env.ts), whose + * cli/acpx-runtime-sidecar.ts stableProviderIdentity feeds runner-core's + * provider_events.rs acpx_opaque_item_id. Durable tool execution IDs keep that + * hash; the separate in-process TypeScript driver's safeId replacement is not + * this path. Mirror the sidecar byte bound followed by the Rust opaque ID. */ +export function bootstrapReadExecutionId(toolCallId: string): string { + if (!toolCallId || toolCallId.length > 240 || /[\u0000-\u001f\u007f]/u.test(toolCallId) || toolCallId.includes("[REDACTED]")) throw new Error("Invalid bootstrap native tool identity"); + const bounded = Buffer.byteLength(toolCallId) > 240 ? hashId("paperclip.acpx.provider-identity.v1\0", toolCallId) : toolCallId; + return /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(bounded) && Buffer.byteLength(bounded) <= 160 ? bounded : hashId("paperclip.acpx.opaque-item.v1\0", bounded); +} + +/** Exempt only completed reads with the exact single-path native attestation on + * every notice. Canonical PRP retains only the first location and may omit path + * and kind on update, so it cannot establish single-file provenance by itself. + * Validate its correlated lifecycle and reject every conflicting explicit path; + * the passive origin attestation supplies cardinality and retained path proof. + * No target is inferred from titles, output, or the first location alone. */ +export function withoutProvenBootstrapReads( + notices: readonly T[], origin: BootstrapReadNotice, proof?: BootstrapReadProof, +): T[] { + if (!proof) return [...notices]; + const expectedReadDigest = `sha256:${createHash("sha256").update(proof.actionFile).digest("hex")}`; + const invalid = () => new Error("Native bootstrap read lacks exact complete action-file proof"); + if (notices.length > 2048 || proof.events.length > 20_000 || !/^\.paperclip-eval-action-[a-f0-9]{36}\.txt$/u.test(proof.actionFile) || !Number.isSafeInteger(origin.seq)) throw invalid(); + const candidates = notices.filter(n => n.operation === "read" && n.seq < origin.seq); + const groups = new Map(); + const nativeIds = new Map(); + const noticeSeq = new Set(); + for (const n of candidates) { + if (n.readTargetSha256 !== expectedReadDigest || n.stage !== "tool" || n.runId !== origin.runId || n.turnId !== origin.turnId || n.sessionId !== origin.sessionId + || !Number.isSafeInteger(n.seq) || n.seq < 0 || noticeSeq.has(n.seq)) throw invalid(); + noticeSeq.add(n.seq); + const executionId = bootstrapReadExecutionId(n.toolCallId); + if (nativeIds.has(executionId) && nativeIds.get(executionId) !== n.toolCallId) throw invalid(); + nativeIds.set(executionId, n.toolCallId); + const group = groups.get(executionId) ?? []; group.push(n); groups.set(executionId, group); + } + const receipts = new Map>(); + const receiptSeq = new Set(); + for (const value of proof.events) { + const row = record(value), event = record(record(row.payload).prpEvent), payload = record(event.payload); + const type = row.eventType; + const family = typeof type === "string" && type.startsWith("tool.execution."); + const correlated = groups.has(payload.executionId); + // An earlier canonical read without its native origin must not disappear. + const earlierRead = family && payload.operation === "read" && row.seq < origin.seq; + if (!correlated && !earlierRead) continue; + if (!family || !["tool.execution.started", "tool.execution.progressed", "tool.execution.completed"].includes(type) + || row.runId !== origin.runId || row.protocolSchemaVersion !== 1 || event.schema !== "paperclip.prp.event.v1" + || event.schemaVersion !== 1 || event.sourceKind !== "runner" || event.runId !== origin.runId || event.turnId !== origin.turnId + || event.eventType !== type || !Number.isFinite(Date.parse(event.emittedAt)) + || payload.schema !== "paperclip.tool.execution.v1" || payload.transport !== "builtin" || !["read", "unknown"].includes(payload.operation) + || (payload.target !== null && payload.target !== proof.actionFile) || !groups.has(payload.executionId) + || !Number.isSafeInteger(row.seq) || row.seq < 0 || row.seq >= origin.seq || receiptSeq.has(row.seq) || noticeSeq.has(row.seq)) throw invalid(); + receiptSeq.add(row.seq); + const group = receipts.get(payload.executionId) ?? []; + group.push({ seq: row.seq, type, status: payload.status, operation: payload.operation }); receipts.set(payload.executionId, group); + } + for (const [id, native] of groups) { + const canonical = receipts.get(id) ?? []; + native.sort((a, b) => a.seq - b.seq); canonical.sort((a, b) => a.seq - b.seq); + if (canonical.length < native.length || canonical.length === 0) throw invalid(); + for (let index = 0; index < native.length; index++) { + const n = native[index]!; + const terminal = n.status === "completed" || n.status === "failed"; + if ((!terminal && n.status !== "pending" && n.status !== "in_progress") || (terminal && index !== native.length - 1)) throw invalid(); + } + if (!["completed", "failed"].includes(native.at(-1)!.status ?? "")) throw invalid(); + for (let index = 0; index < canonical.length; index++) { + const c = canonical[index]!, last = index === canonical.length - 1; + if ((index === 0 && c.operation !== "read") + || (last ? c.type !== "tool.execution.completed" || c.status !== native.at(-1)!.status + : c.status !== "running" || c.type !== (index === 0 ? "tool.execution.started" : "tool.execution.progressed"))) throw invalid(); + } + // Native shape/content updates may omit status. PRP emits a progressed row + // while the passive observer retains its attestation without another notice. + // Such rows remain subject to exact identity, target, order and terminal checks. + // Any later notice for this origin means the supposed bootstrap was not + // complete before the tested action, even if an earlier terminal existed. + if (notices.some(n => n.toolCallId === native[0]!.toolCallId && !native.includes(n))) throw invalid(); + } + const exempt = new Set(candidates); + return notices.filter(n => !exempt.has(n)); +} diff --git a/tests/runner-e2e/native-local-fixtures.ts b/tests/runner-e2e/native-local-fixtures.ts new file mode 100644 index 00000000000..0179869c228 --- /dev/null +++ b/tests/runner-e2e/native-local-fixtures.ts @@ -0,0 +1,199 @@ +import { spawn, execFileSync, type ChildProcess } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { watch, lstatSync, type FSWatcher } from "node:fs"; +import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer, type Socket } from "node:net"; +import { basename, join, relative } from "node:path"; + +export const sha256 = (value: string) => `sha256:${createHash("sha256").update(value).digest("hex")}`; +const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; +export async function exists(path: string): Promise { + try { await lstat(path); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; } +} +/** Allocate before dispatch; ordinary workspace startup cannot mutate this parent. */ +export async function createDeniedTargetFixture(workspacePath: string, name: string) { + if (!name || basename(name) !== name || name === "." || name === "..") throw new Error("Invalid denied target name"); + const directory = await mkdtemp(join(workspacePath, "pc-denied-")); + const targetPath = join(directory, name); + return { directory, targetPath, targetRelativePath: relative(workspacePath, targetPath), watcher: watchDeniedTarget(directory, name) }; +} + +/** Substitute the one authored target, never append a contradictory second path. */ +export function bindDeniedTargetPrompt(prompt: string, original: string, target: string): string { + const parts = prompt.split(original); + if (parts.length !== 2) throw new Error("Denied prompt must name its exact target once"); + return parts.join(target); +} + +export function watchDeniedTarget(directory: string, name: string) { + if (!name || basename(name) !== name || name === "." || name === "..") throw new Error("Invalid denied target name"); + const startedAtMs = Date.now(); let targetMutationCount = 0; + const reasons = new Set(); + const before = lstatSync(directory, { bigint: true }); + if (!before.isDirectory() || before.isSymbolicLink()) throw new Error("Denied target parent must be a real directory"); + const targetAbsent = () => { try { lstatSync(join(directory, name)); return false; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; throw error; } }; + if (!targetAbsent()) throw new Error("Denied target must initially be absent"); + const identity = (stat: import("node:fs").BigIntStats) => ({ dev: String(stat.dev), ino: String(stat.ino), mtimeNs: String(stat.mtimeNs), ctimeNs: String(stat.ctimeNs) }); + const events: Array<{ sequence: number; observedAtMs: number; kind: string; target: boolean; filenameKnown: boolean }> = []; + let eventCount = 0, lastEventAtMs = startedAtMs, closing = false; + let final: { startedAtMs: number; endedAtMs: number; complete: boolean; targetMutationCount: number; reasons: string[]; initialParent: ReturnType; finalParent: ReturnType | null; events: typeof events } | undefined; + const watcher: FSWatcher = watch(directory, (kind, filename) => { + const observedAtMs = Date.now(); + if (observedAtMs < lastEventAtMs) reasons.add("event-order-invalid"); + lastEventAtMs = observedAtMs; + const target = filename !== null && String(filename) === name; + if (filename === null) reasons.add("event-filename-missing"); + if (target) targetMutationCount++; + if (++eventCount <= 128) events.push({ sequence: eventCount, observedAtMs, kind, target, filenameKnown: filename !== null }); + else reasons.add("event-journal-overflow"); + }); + watcher.on("error", () => { reasons.add("watch-error"); }); + watcher.on("close", () => { if (!closing) reasons.add("watch-closed-before-finish"); }); + // Pin both identity and directory version across watcher installation. + try { + const armed = lstatSync(directory, { bigint: true }); + if (!armed.isDirectory() || armed.dev !== before.dev || armed.ino !== before.ino) reasons.add("parent-identity-changed-during-arm"); + if (armed.mtimeNs !== before.mtimeNs || armed.ctimeNs !== before.ctimeNs || !targetAbsent()) reasons.add("coverage-gap-during-arm"); + } catch { reasons.add("parent-unavailable-during-arm"); } + return { finish() { + if (final) return final; + let after: import("node:fs").BigIntStats | undefined; + try { after = lstatSync(directory, { bigint: true }); } catch { reasons.add("parent-unavailable-at-finish"); } + // FSEvents may coalesce a rapid create/delete. A changed directory version + // with no attributed event is a coverage gap, never proof of no mutation. + if (after && (after.dev !== before.dev || after.ino !== before.ino || !after.isDirectory())) reasons.add("parent-identity-changed"); + if (after && (after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) && targetMutationCount === 0) reasons.add("coverage-gap-parent-version-changed"); + try { if (!targetAbsent() && targetMutationCount === 0) reasons.add("coverage-gap-unobserved-target"); } catch { reasons.add("target-unavailable-at-finish"); } + const endedAtMs = Date.now(); + if (endedAtMs < lastEventAtMs) reasons.add("event-order-invalid"); + final = { startedAtMs, endedAtMs, complete: reasons.size === 0, targetMutationCount, reasons: [...reasons], initialParent: identity(before), finalParent: after ? identity(after) : null, events }; + closing = true; watcher.close(); return final; + } }; +} +interface ProcessIdentity { pid: number; parent: number; start: string } +function processTable(): ProcessIdentity[] { + const output = execFileSync("/bin/ps", ["-axo", "pid=,ppid=,lstart="], { encoding: "utf8", timeout: 3000, maxBuffer: 8 * 1024 * 1024, env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } }); + return output.split("\n").flatMap(line => { const m = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/u.exec(line); return m ? [{ pid: Number(m[1]), parent: Number(m[2]), start: m[3]! }] : []; }); +} +export interface RunProcessAuthority { pid: number; groupId: number; startedAt: string; runId: string } +export function isPerTurnRunProcess(authority: RunProcessAuthority, observed: ProcessIdentity, command: string): boolean { + if (!/^[a-zA-Z0-9_-]{1,128}$/u.test(authority.runId) || authority.pid !== observed.pid || authority.groupId !== observed.pid) return false; + const started = Date.parse(authority.startedAt), actual = Date.parse(observed.start); + if (!Number.isFinite(started) || !Number.isFinite(actual) || Math.floor(started / 1000) !== Math.floor(actual / 1000)) return false; + const args = command.trim().split(/\s+/u); + return [["--run-id", authority.runId], ["--lifecycle-mode", "per_turn"]].every(([flag, value]) => { + const at = args.indexOf(flag!); return at >= 0 && args.lastIndexOf(flag!) === at && args[at + 1] === value; + }); +} +/** Read-only PID/start journal; never signal an API-reported or reused PID. + * Native onSpawn publishes the runnerd child. Bind its current argv to this run + * and per_turn lifecycle before treating its retirement as run cleanup. + */ +export function observeRunProcesses() { + const owned = new Map(); let root: ProcessIdentity | undefined; + return { + sample(authority?: RunProcessAuthority) { + const table = processTable(); + if (!root && authority && authority.pid > 1 && authority.pid !== process.pid) { + const candidate = table.find(p => p.pid === authority.pid); + if (candidate) { + let command: string; + try { command = execFileSync("/bin/ps", ["-p", String(candidate.pid), "-o", "command="], { encoding: "utf8", timeout: 3000, maxBuffer: 64 * 1024 }); } + catch { command = ""; } // A vanished/uninspectable PID supplies no authority. + if (isPerTurnRunProcess(authority, candidate, command) && processTable().some(p => p.pid === candidate.pid && p.start === candidate.start)) { root = candidate; owned.set(root.pid, root); } + } + } + let changed = true; + while (changed) { + changed = false; + for (const p of table) if (!owned.has(p.pid)) { + const parent = owned.get(p.parent); + if (parent && table.some(t => t.pid === parent.pid && t.start === parent.start)) { owned.set(p.pid, p); changed = true; } + } + } + return { captured: Boolean(root), journal: [...owned.values()], live: table.filter(p => owned.get(p.pid)?.start === p.start).map(p => p.pid) }; + }, + }; +} + +/** A one-shot local fixture, not a command service: input selects no executable, + * arguments or file path. The test owns/reaps the fixed finite child, records its + * OS exit status, and holds the exact provider-launched client until that exit. + */ +export async function createAttachedCommandFixture(markerPath: string, delayMs = 4000) { + if (!Number.isInteger(delayMs) || delayMs < 100 || delayMs > 8000) throw new Error("Invalid bounded fixture delay"); + const root = await mkdtemp("/tmp/pc-copilot-"); await mkdir(join(root, "private"), { mode: 0o700 }); + const socketPath = join(root, "private", "socket"), scriptPath = join(root, "client.cjs"); + const nonce = randomBytes(16).toString("hex"), marker = `${randomBytes(24).toString("hex")}\n`; + const script = `const net=require('node:net');const s=net.connect(process.argv[2]);let b='';s.setTimeout(15000,()=>process.exit(3));s.on('error',()=>process.exit(4));s.on('connect',()=>s.write(JSON.stringify({nonce:process.argv[3],pid:process.pid})+'\\n'));s.on('data',x=>{b+=x;if(b.includes('\\n')){const r=JSON.parse(b);s.end();process.exit(r.code===0?0:5);}});`; + await writeFile(scriptPath, script, { mode: 0o400 }); + const command = `${quote(process.execPath)} ${quote(scriptPath)} ${quote(socketPath)} ${quote(nonce)}`; + const commandSha256 = sha256(command); + let markerWrittenAtMs: number | null = null; + let clientExitedAtMs: number | null = null; + let clientObservation: ReturnType | undefined; + let connections = 0, child: ChildProcess | undefined, client: ProcessIdentity | undefined, failure: string | null = null; + let exit: { observedAtMs: number; code: number; ownedProcessIdentityVerified: boolean; commandSha256: string } | null = null; + const sockets = new Set(); + let closed = false; + const server = createServer(socket => { + sockets.add(socket); socket.on("close", () => sockets.delete(socket)); socket.on("error", () => { failure = "fixture_socket_error"; }); + let buffer = ""; + socket.on("data", data => { + buffer += data; if (buffer.length > 1024) { failure = "fixture_request_too_large"; socket.destroy(); return; } + if (!buffer.includes("\n")) return; + connections++; + try { + const request = JSON.parse(buffer); buffer = ""; + if (connections !== 1 || request.nonce !== nonce || !Number.isSafeInteger(request.pid) || request.pid <= 1) throw new Error("fixture_request_invalid"); + const observed = processTable().find(p => p.pid === request.pid); + const argv = execFileSync("/bin/ps", ["-p", String(request.pid), "-o", "command="], { encoding: "utf8", timeout: 3000 }); + if (!observed || !argv.includes(scriptPath) || !argv.includes(socketPath) || !argv.includes(nonce)) throw new Error("fixture_client_identity_invalid"); + client = observed; + clientObservation = setInterval(() => { + try { + if (!processTable().some(p => p.pid === client!.pid && p.start === client!.start)) { + clientExitedAtMs = Date.now(); clearInterval(clientObservation); + } + } catch { failure = "fixture_client_observation_failed"; clearInterval(clientObservation); } + }, 25); + child = spawn(process.execPath, ["-e", `setTimeout(()=>process.exit(0),${delayMs})`], { env: { PATH: "/usr/bin:/bin" }, stdio: "ignore" }); + child.once("error", () => { failure = "fixture_child_start_failed"; socket.destroy(); }); + child.once("exit", (code, signal) => { + exit = { observedAtMs: Date.now(), code: code ?? -1, ownedProcessIdentityVerified: Number.isInteger(child?.pid), commandSha256 }; + void (async () => { + if (code !== 0 || signal) { failure = "fixture_child_failed"; socket.destroy(); return; } + if (await readFile(scriptPath, "utf8") !== script) { failure = "fixture_script_changed"; socket.destroy(); return; } + await writeFile(markerPath, marker, { flag: "wx" }); markerWrittenAtMs = Date.now(); socket.end(`${JSON.stringify({ code })}\n`); + })().catch(() => { failure = "fixture_completion_failed"; socket.destroy(); }); + }); + } catch { failure = "fixture_request_rejected"; socket.destroy(); } + }); + }); + try { await new Promise((resolve, reject) => { server.once("error", reject); server.listen(socketPath, resolve); }); } + catch (error) { server.close(); await rm(root, { recursive: true, force: true }); throw error; } + return { + command, commandSha256, marker, + snapshot() { const table = processTable(); return { connections, failure, markerWrittenAtMs, clientExitedAtMs, commandExit: exit, childPid: child?.pid ?? null, clientPid: client?.pid ?? null, clientGone: Boolean(client) && !table.some(p => p.pid === client!.pid && p.start === client!.start), childGone: Boolean(exit), observedAtMs: Date.now() }; }, + async close() { + if (closed) return; closed = true; clearInterval(clientObservation); + let cleanupError: unknown; + try { + if (child && child.exitCode === null && child.signalCode === null) { + await new Promise((resolve, reject) => { + const hard = setTimeout(() => { child!.kill("SIGKILL"); }, 3000); + const deadline = setTimeout(() => reject(new Error("Fixture child did not settle within cleanup bound")), 5000); + child!.once("exit", () => { clearTimeout(hard); clearTimeout(deadline); resolve(); }); child!.kill("SIGTERM"); + }); + } + } catch (error) { cleanupError = error; } + finally { + for (const socket of sockets) socket.destroy(); + try { await new Promise(resolve => server.close(() => resolve())); } + finally { await rm(root, { recursive: true, force: true }); } + } + if (cleanupError) throw cleanupError; + + }, + }; +} diff --git a/tests/runner-e2e/native-remote-evidence.ts b/tests/runner-e2e/native-remote-evidence.ts new file mode 100644 index 00000000000..436a02af27e --- /dev/null +++ b/tests/runner-e2e/native-remote-evidence.ts @@ -0,0 +1,66 @@ +export interface NativeRemoteBinding { + companyId: string; environmentId: string; runId: string; leaseId: string; sandboxId: string; image: string; remoteCwd: string; +} +export interface NativeRemoteSnapshot { + binding: NativeRemoteBinding; observedAtMs: number; receivedAtMs: number; observedMonotonicNs: string; complete: boolean; + workspace: Record; + targets: Record; + watcher: { complete: boolean; targetMutationCount: number; workspaceMutationCount: number }; + processes: { captured: boolean; root: { pid: number; startTicks: string; bootId: string } | null; journal: Array<{ pid: number; ppid: number; startTicks: string; bootId: string }>; live: number[] }; + setup: { path: string; sha256: string | null; published: boolean }; + attached: { connections: number; failure: string | null; commandExit: { code: number; observedAtMs: number; observedMonotonicNs: string } | null; + markerWrittenAtMs: number | null; markerWrittenMonotonicNs: string | null; clientExitedAtMs: number | null; clientExitedMonotonicNs: string | null } | null; +} +export interface NativeRemoteFixture { + binding: NativeRemoteBinding; remoteCwd: string; actionFile: string; + snapshot(label: string): Promise; + setupAttachedCommand(input: { marker: string; markerText: string; delayMs: number }): Promise<{ command: string; commandSha256: string }>; + finish(): Promise; readFile(relative: string): Promise; close(): Promise; +} +export interface NativeRemoteBootstrap { + prompt(nonce: string): string; + bindAndRelease(input: { issueId: string; runId: string; targets: readonly string[]; + actionPrompt(fixture: NativeRemoteFixture): Promise | string }): Promise; +} +export function assertNativeRemoteSnapshot(s: NativeRemoteSnapshot, binding: NativeRemoteBinding): void { + const keys: Array = ["companyId", "environmentId", "runId", "leaseId", "sandboxId", "image", "remoteCwd"]; + if (!s.complete || !s.watcher.complete || !keys.every(k => typeof binding[k] === "string" && binding[k].length > 0 && s.binding[k] === binding[k]) + || !/^.+@sha256:[a-f0-9]{64}$/u.test(binding.image) || !binding.remoteCwd.startsWith("/") || binding.remoteCwd.split("/").some(p => p === ".." || p === ".") + || !Number.isSafeInteger(s.observedAtMs) || s.observedAtMs < 0 || !Number.isSafeInteger(s.receivedAtMs) || !/^\d+$/u.test(s.observedMonotonicNs) + || ![s.watcher.targetMutationCount, s.watcher.workspaceMutationCount].every(n => Number.isSafeInteger(n) && n >= 0)) throw new Error("Incomplete Native remote lease/watch receipt"); +} +export function assertNativeRemoteRetirement(s: NativeRemoteSnapshot, baseline: NativeRemoteSnapshot): void { + assertNativeRemoteSnapshot(s, baseline.binding); + const root = s.processes.root, original = baseline.processes.root; + if (!root || !original || !baseline.processes.captured || !s.processes.captured || s.processes.live.length !== 0 + || root.pid !== original.pid || root.startTicks !== original.startTicks || root.bootId !== original.bootId + || !Number.isSafeInteger(root.pid) || root.pid < 2 || !/^\d+$/u.test(root.startTicks) || !/^[a-f0-9-]{36}$/iu.test(root.bootId) + || !s.processes.journal.some(p => p.pid === root.pid && p.startTicks === root.startTicks && p.bootId === root.bootId) + || !s.processes.journal.every(p => p.bootId === root.bootId && /^\d+$/u.test(p.startTicks) && Number.isSafeInteger(p.pid) && p.pid > 1) + || !s.setup.published || s.setup.path !== baseline.setup.path || !/^sha256:[a-f0-9]{64}$/u.test(s.setup.sha256 ?? "") + || BigInt(s.observedMonotonicNs) < BigInt(baseline.observedMonotonicNs)) throw new Error("Native remote retirement is unproven"); +} +export function nativeRemoteDeniedSample(s: NativeRemoteSnapshot, baseline: NativeRemoteSnapshot, target: string, phase: "before-request" | "pending" | "after-decision" | "terminal" | "after-cleanup") { + assertNativeRemoteSnapshot(s, baseline.binding); + const t = s.targets[target], before = baseline.targets[target]; + if (!t?.complete || !before?.complete || !/^\d+$/u.test(t.parent.dev) || !/^\d+$/u.test(t.parent.ino) + || t.parent.dev !== before.parent.dev || t.parent.ino !== before.parent.ino || t.mutationCount !== 0 + || s.watcher.targetMutationCount !== 0 || s.watcher.workspaceMutationCount !== baseline.watcher.workspaceMutationCount + || JSON.stringify(Object.entries(s.workspace).sort()) !== JSON.stringify(Object.entries(baseline.workspace).sort())) throw new Error("Native remote denied target changed or observation was incomplete"); + return { phase, observedAtMs: s.observedAtMs, exists: t.absent !== true || t.sha256 !== null }; +} +/** Await baseline and exact command construction before the bootstrap can publish. */ +export async function prepareNativeRemoteAction(input: { + fixture: NativeRemoteFixture; companyId: string; environmentId: string; runId: string; + target: string; prompt: string; markerText?: string; +}) { + const f = input.fixture; + if (f.binding.companyId !== input.companyId || f.binding.environmentId !== input.environmentId || f.binding.runId !== input.runId || f.remoteCwd !== f.binding.remoteCwd) throw new Error("Foreign Native remote bootstrap binding"); + const command = input.markerText === undefined ? undefined : await f.setupAttachedCommand({ marker: input.target, markerText: input.markerText, delayMs: 4000 }); + const baseline = await f.snapshot("before-action-publication"); assertNativeRemoteSnapshot(baseline, f.binding); + if (baseline.setup.published || baseline.setup.path !== f.actionFile || !baseline.processes.captured || baseline.processes.live.length === 0) throw new Error("Native action was not held behind the remote observer"); + const target = baseline.targets[input.target]; + if (!target?.complete || !target.absent || target.sha256 !== null || target.mutationCount !== 0) throw new Error("Native remote target was present or unobserved before action"); + return { baseline, command, prompt: `${input.prompt}\nThe admitted remote workspace is ${f.remoteCwd}.${command ? `\nThe exact supplied command is:\n${command.command}\nDo not inspect or modify fixture code, fabricate its marker, or launch a substitute command.` : ""}` }; +} + diff --git a/tests/runner-e2e/prerequisites.ts b/tests/runner-e2e/prerequisites.ts index 45415b51f04..7a2020be28e 100644 --- a/tests/runner-e2e/prerequisites.ts +++ b/tests/runner-e2e/prerequisites.ts @@ -12,6 +12,18 @@ export const UNQUALIFIED_PROFILE_GAPS = { pi: "Pi has no qualified model source, so no valid Product E2E profile is registered.", } as const; +/** Reject missing remote observer authority before creating a company or run. */ +export function assertRemoteNativeEvidencePrerequisites( + executions: readonly MatrixExecution[], + environment: NodeJS.ProcessEnv, +): void { + if (!executions.some(execution => execution.environment.id === "daytona" + && ["cursor_native", "native_active_stop"].includes(execution.task.flow))) return; + const names = ["PAPERCLIP_E2E_DAYTONA_NODE_SHA256", "PAPERCLIP_E2E_DAYTONA_RUNNERD_SHA256"]; + const invalid = names.filter(name => !/^sha256:[a-f0-9]{64}$/u.test(environment[name] ?? "")); + if (invalid.length) throw new Error(`Native Daytona evidence requires exact image executable digests: ${invalid.join(", ")}`); +} + export function assertRunnerE2EPrerequisites( executions: readonly MatrixExecution[], ): void { diff --git a/tests/runner-e2e/process-tree-owner.test.ts b/tests/runner-e2e/process-tree-owner.test.ts new file mode 100644 index 00000000000..b3fa3b45434 --- /dev/null +++ b/tests/runner-e2e/process-tree-owner.test.ts @@ -0,0 +1,120 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { expect, it } from "vitest"; +import { createProcessTreeOwner } from "./process-tree-owner.js"; +import { readProcessTable, type ProcessObservation } from "./process-tree.js"; + +function row(pid: number, parentPid: number, processGroupId: number, started = `start-${pid}`, state = "S"): ProcessObservation { + return { pid, parentPid, processGroupId, started, state, kind: "node" }; +} +function fixture(initial: ProcessObservation[]) { + let table = [row(process.pid, 1, 10), ...initial]; + const root = { pid: 100, exitCode: null as number | null, signalCode: null as NodeJS.Signals | null }; + const signals: Array<[number, NodeJS.Signals]> = []; + const owner = createProcessTreeOwner(root as ChildProcess, { + readTable: async () => table, + signalGroup: (pid, signal) => { signals.push([pid, signal]); }, + }); + return { root, owner, signals, replace: (rows: ProcessObservation[]) => { table = [row(process.pid, 1, 10), ...rows]; } }; +} + +it.skipIf(process.platform === "win32")("revalidates PID/start before signaling and refuses a recycled group", async () => { + const f = fixture([row(100, process.pid, 100), row(200, 100, 200)]); + try { + await f.owner.observe(); + f.replace([row(100, process.pid, 100), row(200, 1, 200, "new-start")]); + await expect(f.owner.signal("SIGKILL")).rejects.toThrow("identity became uncertain"); + expect(f.signals).toEqual([]); + } finally { f.owner.stopObserving(); } +}); + +it.skipIf(process.platform === "win32")("retains observed descendants when the root exits and excludes the caller group", async () => { + const f = fixture([row(100, process.pid, 100), row(200, 100, 200), row(300, 100, 10), row(999, process.pid, 999)]); + try { + await f.owner.observe(); + f.root.exitCode = 1; + f.replace([row(200, 1, 200), row(300, 1, 10), row(999, process.pid, 999)]); + await f.owner.signal("SIGTERM"); + expect(f.signals).toEqual([[200, "SIGTERM"]]); + } finally { f.owner.stopObserving(); } +}); + +it.skipIf(process.platform === "win32")("treats an unreaped zombie as stopped without signaling a replacement", async () => { + const f = fixture([row(100, process.pid, 100), row(200, 100, 200)]); + try { + await f.owner.observe(); + f.root.exitCode = 1; + f.replace([row(200, 1, 200, "start-200", "Z")]); + await f.owner.signal("SIGKILL"); + expect(f.owner.liveGroups()).toEqual([]); + expect(f.signals).toEqual([]); + } finally { f.owner.stopObserving(); } +}); + +it.skipIf(process.platform === "win32")("sends launcher grace only to the root while retaining nested groups for escalation", async () => { + const f = fixture([row(100, process.pid, 100), row(200, 100, 200), row(300, 200, 300)]); + try { + await f.owner.observe(); + await f.owner.signal("SIGTERM", f.owner.gracefulRoots()); + expect(f.signals).toEqual([[100, "SIGTERM"]]); + f.root.exitCode = 1; + f.replace([row(200, 1, 200), row(300, 200, 300)]); + await f.owner.observe(); + expect([...f.owner.gracefulRoots()]).toEqual([200]); + await f.owner.signal("SIGKILL"); + expect(f.signals.slice(1).map(([pid]) => pid).sort()).toEqual([200, 300]); + } finally { f.owner.stopObserving(); } +}); + +it.skipIf(process.platform === 'win32')('reselects a vanished owner only when no graceful signal was delivered', async () => { + let table = [row(process.pid, 1, 10), row(100, process.pid, 100), row(200, 100, 200)]; + const child = { pid: 100, exitCode: null as number | null, signalCode: null }; + const delivered: number[] = []; + let reads = 0; + let stopping = false; + const owner = createProcessTreeOwner(child as ChildProcess, { + readTable: async () => { + if (stopping && ++reads === 2) { child.exitCode = 0; table = [row(process.pid, 1, 10), row(200, 1, 200)]; } + return table; + }, + signalGroup: (pid, signal) => { + expect(signal).toBe('SIGTERM'); + if (pid === 100) { + child.exitCode = 0; table = [row(process.pid, 1, 10), row(200, 1, 200)]; + throw Object.assign(new Error('vanished'), { code: 'ESRCH' }); + } + delivered.push(pid); table = [row(process.pid, 1, 10)]; + }, + }); + try { + await owner.observe(); stopping = true; + const { stopOwnedProcessTree } = await import('./process-tree-owner.js'); + await stopOwnedProcessTree(child as ChildProcess, owner, 150, 100); + expect(delivered).toEqual([200]); + } finally { owner.stopObserving(); } +}); + + +it.skipIf(process.platform === "win32")("rejects an overflowing inspector instead of returning a partial identity table", async () => { + const inspector = spawn(process.execPath, ["-e", "process.stdout.write('x'.repeat(2 * 1024 * 1024)); setInterval(() => {}, 1000)"], { + stdio: ["ignore", "pipe", "ignore"], + }); + const exited = new Promise(resolve => inspector.once("exit", () => resolve())); + try { + expect(await readProcessTable(() => inspector)).toBeNull(); + await exited; + expect(inspector.signalCode).toBe("SIGKILL"); + } finally { if (inspector.exitCode === null && inspector.signalCode === null) inspector.kill("SIGKILL"); } +}); + +it.skipIf(process.platform === 'win32')('bounds fallback even when the known direct child cannot be killed', async () => { + const signals: NodeJS.Signals[] = []; + const child = { pid: 100, exitCode: null, signalCode: null, + kill: (signal: NodeJS.Signals) => { signals.push(signal); return false; } } as ChildProcess; + const owner = createProcessTreeOwner(child, { readTable: async () => null, + signalGroup: () => { throw new Error('must not signal an unobserved group'); } }); + try { + const { stopOwnedProcessTree } = await import('./process-tree-owner.js'); + await expect(stopOwnedProcessTree(child, owner, 10, 10)).rejects.toThrow('Known direct child remained'); + expect(signals).toEqual(['SIGTERM', 'SIGKILL']); + } finally { owner.stopObserving(); } +}); diff --git a/tests/runner-e2e/process-tree-owner.ts b/tests/runner-e2e/process-tree-owner.ts new file mode 100644 index 00000000000..a5da0dbbd7f --- /dev/null +++ b/tests/runner-e2e/process-tree-owner.ts @@ -0,0 +1,188 @@ +import type { ChildProcess } from "node:child_process"; +import { + observeDescendantProcessTree, readProcessTable, revalidateObservedProcessGroups, + refreshContinuouslyLiveProcessGroups, safeProcessGroupTerminationOrder, + type ObservedProcessGroup, type ProcessObservation, +} from "./process-tree.js"; + +export function createProcessTreeOwner(root: ChildProcess, options: { + readTable?: () => Promise; + signalGroup?: (pid: number, signal: NodeJS.Signals) => void; +} = {}) { + let groups: ObservedProcessGroup[] = []; + let table: ProcessObservation[] = []; + let rootStarted: string | undefined; + let observing: Promise | undefined; + const covered = new Set(); + const readTable = options.readTable ?? readProcessTable; + const signalGroup = options.signalGroup ?? ((pid, signal) => process.kill(-pid, signal)); + const exited = () => root.exitCode !== null || root.signalCode !== null || !root.pid; + const running = (candidate: ProcessObservation) => !candidate.state?.startsWith("Z"); + + function observe(): Promise { + if (process.platform === "win32") return Promise.resolve(); + if (observing) return observing; + observing = (async () => { + const next = await readTable(); + if (!next) throw new Error("Could not inspect owned process identities"); + const callerGroup = next.find(row => row.pid === process.pid)?.processGroupId; + if (callerGroup === undefined) throw new Error("Cleanup caller group identity is unavailable"); + const rootRow = next.find(row => row.pid === root.pid); + if (!rootStarted && !exited()) rootStarted = rootRow?.started; + const retained = revalidateObservedProcessGroups(groups, next); + // Never acquire a recycled group from its numeric ID alone. + const lost = groups.filter(group => !retained.includes(group) && next.some(row => row.processGroupId === group.processGroupId && running(row))); + if (lost.length) throw new Error("Owned process group identity became uncertain"); + const refreshed = refreshContinuouslyLiveProcessGroups(retained, next) + .filter(group => group.processGroupId !== callerGroup); + const anchors = refreshed.flatMap(group => group.members.map(member => member.pid)); + if (rootRow && rootStarted === rootRow.started) anchors.push(rootRow.pid); + const byGroup = new Map(refreshed.map(group => [group.processGroupId, group])); + for (const pid of anchors) { + for (const group of observeDescendantProcessTree(next, pid).groups) { + if (group.processGroupId !== callerGroup) { + byGroup.set(group.processGroupId, group); + if (covered.has(next.find(row => row.pid === pid)!.processGroupId)) covered.add(group.processGroupId); + } + } + } + groups = [...byGroup.values()]; + table = next; + })().finally(() => { observing = undefined; }); + return observing; + } + const timer = process.platform === "win32" ? undefined : setInterval(() => { + void observe().catch(() => { /* Stop must obtain a fresh successful inspection. */ }); + }, 250); + timer?.unref(); + void observe().catch(() => {}); + + function liveGroups() { + return groups.filter(group => table.some(row => row.processGroupId === group.processGroupId && running(row))); + } + function signalSnapshot(signal: NodeJS.Signals, selected?: ReadonlySet) { + const currentProcessGroupId = table.find(row => row.pid === process.pid)?.processGroupId ?? null; + if (currentProcessGroupId === null) throw new Error("Cleanup caller group identity is unavailable"); + const live = liveGroups(); + const ordered = safeProcessGroupTerminationOrder({ rootProcessGroupId: root.pid ?? -1, currentProcessGroupId, groups: live }); + const signaled: number[] = []; + for (const pid of ordered) { + if (selected && !selected.has(pid)) continue; + try { signalGroup(pid, signal); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; else continue; } + signaled.push(pid); + } + return signaled; + } + async function signal(signal: NodeJS.Signals, selected?: ReadonlySet) { + await observe(); + return signalSnapshot(signal, selected); + } + function gracefulRoots() { + const live = liveGroups(); + const ids = new Set(live.map(group => group.processGroupId)); + if (root.pid && ids.has(root.pid)) return new Set([root.pid]); + // After the launcher's leader exits, request shutdown only from the + // outermost remaining owners; their wrappers still own child signaling. + const byPid = new Map(table.map(row => [row.pid, row])); + return new Set(live.filter(group => !table.some(row => { + if (row.processGroupId !== group.processGroupId) return false; + const seen = new Set(); + let parent = byPid.get(row.parentPid); + while (parent && !seen.has(parent.pid)) { + seen.add(parent.pid); + if (parent.processGroupId !== group.processGroupId && ids.has(parent.processGroupId)) return true; + parent = byPid.get(parent.parentPid); + } + return false; + })).map(group => group.processGroupId)); + } + let graceComplete = false; + let directGraceDelivered = false; + async function signalGracefully() { + if (graceComplete) return; + await observe(); + // Choose and deliver from one validated table. ESRCH grants no ownership + // coverage: the next grace poll can select that branch's surviving owner. + const selected = new Set([...gracefulRoots()].filter(pid => !covered.has(pid))); + const delivered = signalSnapshot("SIGTERM", selected); + for (const pid of delivered) { + if (pid === root.pid) directGraceDelivered = true; + covered.add(pid); + for (const member of table.filter(row => row.processGroupId === pid)) { + for (const group of observeDescendantProcessTree(table, member.pid).groups) covered.add(group.processGroupId); + } + } + // Never reselect descendants after their owner received grace, even if it + // exits before their asynchronous close completes. + graceComplete = selected.size > 0 && delivered.length === selected.size; + } + return { observe, signal, liveGroups, gracefulRoots, signalGracefully, + directGraceDelivered: () => directGraceDelivered, stopObserving: () => { if (timer) clearInterval(timer); } }; +} + + +/** Inspection failure grants no group authority. The unreaped ChildProcess + * handle still owns its direct child, so retire only that child within the + * original grace deadline, then report the incomplete tree audit. */ +export async function stopKnownDirectChild(child: ChildProcess, phase: { gracefulSent: boolean; forcedDeadline?: number }, + deadline: number, forcedMs: number, signal: NodeJS.Signals = "SIGTERM") { + const exited = () => child.exitCode !== null || child.signalCode !== null || !child.pid; + const wait = () => new Promise(resolve => setTimeout(resolve, 25)); + if (!exited() && !phase.gracefulSent) { + phase.gracefulSent = child.kill(signal); + } + while (!exited() && Date.now() < deadline) await wait(); + if (exited()) return; + child.kill("SIGKILL"); + phase.forcedDeadline ??= Date.now() + forcedMs; + while (!exited() && Date.now() < phase.forcedDeadline) await wait(); + if (!exited()) throw new Error("Known direct child remained after bounded fallback"); +} + +export async function incompleteTreeFallback(error: unknown, child: ChildProcess, + phase: { gracefulSent: boolean; forcedDeadline?: number }, deadline: number, forcedMs: number) { + let fallbackError: unknown; + try { await stopKnownDirectChild(child, phase, deadline, forcedMs); } + catch (failure) { fallbackError = failure; } + throw new Error(`Owned tree cleanup incomplete: ${String(error)}${fallbackError ? `; ${String(fallbackError)}` : ""}`); +} + +/** Give the launcher/wrapper chain sole graceful-signal ownership, then retire + * only still-observed descendants. Used on normal exit as well as cancellation. */ +export async function stopOwnedProcessTree( + child: ChildProcess, + owner: ReturnType, + gracefulMs = 45_000, + forcedMs = 5_000, +): Promise { + const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + const deadline = Date.now() + gracefulMs; + const phase: { gracefulSent: boolean; forcedDeadline?: number } = { gracefulSent: false }; + try { + if (process.platform === "win32") { + await stopKnownDirectChild(child, phase, deadline, forcedMs); + return; + } + const stopped = async () => { + await owner.observe(); + return (child.exitCode !== null || child.signalCode !== null) && owner.liveGroups().length === 0; + }; + do { + await owner.signalGracefully(); + phase.gracefulSent ||= owner.directGraceDelivered(); + if (await stopped()) return; + await wait(50); + } while (Date.now() < deadline); + await owner.signal("SIGKILL"); + phase.forcedDeadline = Date.now() + forcedMs; + do { + if (await stopped()) return; + await wait(50); + } while (Date.now() < phase.forcedDeadline); + throw new Error("Observed launcher descendants remained after bounded cleanup"); + } catch (error) { + phase.gracefulSent ||= owner.directGraceDelivered(); + await incompleteTreeFallback(error, child, phase, deadline, forcedMs); + } +} diff --git a/tests/runner-e2e/process-tree.ts b/tests/runner-e2e/process-tree.ts index a7f0824b0fa..1ab4ebcddc9 100644 --- a/tests/runner-e2e/process-tree.ts +++ b/tests/runner-e2e/process-tree.ts @@ -1,9 +1,13 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import path from "node:path"; + export interface ProcessObservation { pid: number; parentPid: number; processGroupId: number; started: string; kind: string; + state?: string; } export interface ObservedProcessGroup { @@ -140,3 +144,86 @@ export function refreshContinuouslyLiveProcessGroups( : []; }); } + +const diagnosticProcessKinds = new Set([ + "bash", + "chrome", + "codex", + "google-chrome", + "node", + "paperclip-runnerd", + "playwright", + "pnpm", + "postgres", + "sh", + "tsx", +]); + +export async function readProcessTable(startInspector?: () => ChildProcess): Promise { + if (process.platform === "win32") { + return null; + } + return await new Promise((resolve) => { + const inspector = startInspector ? startInspector() : spawn( + "ps", + ["-e", "-o", "pid=,ppid=,pgid=,lstart=,stat=,comm="], + { + env: { PATH: "/usr/bin:/bin", LANG: "C", LC_ALL: "C" }, + stdio: ["ignore", "pipe", "ignore"], + // A wrapper-group shutdown must not interrupt its own bounded audit. + detached: true, + }, + ); + let output = ""; + let settled = false; + let truncated = false; + const finish = (value: ProcessObservation[] | null) => { + if (settled) return; + settled = true; + clearTimeout(inspectionTimeout); + resolve(value); + }; + const inspectionTimeout = setTimeout(() => { + inspector.kill("SIGKILL"); + finish(null); + }, 5_000); + inspectionTimeout.unref(); + inspector.stdout?.setEncoding("utf8").on("data", (chunk: string) => { + if (output.length + chunk.length > 1024 * 1024) { + truncated = true; + inspector.kill("SIGKILL"); + finish(null); + return; + } + output += chunk; + }); + inspector.once("error", () => finish(null)); + inspector.once("close", (code) => { + if (code !== 0 || truncated) { finish(null); return; } + const observations = output + .split(/\r?\n/) + .map((line) => + /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(\S+)\s+(.+?)\s*$/.exec( + line, + ), + ) + .filter((match): match is RegExpExecArray => match !== null) + .map((match): ProcessObservation => { + // A target process can choose its own argv and process name. Emit a + // fixed category instead of target-controlled text so diagnostics + // can never turn that metadata into a secret-exfiltration channel. + const command = path.basename(match[6]!); + const kind = diagnosticProcessKinds.has(command) ? command : "other"; + return { + pid: Number(match[1]), + parentPid: Number(match[2]), + processGroupId: Number(match[3]), + started: match[4]!, + kind, + state: match[5]!, + }; + }); + finish(observations); + }); + }).catch(() => null); +} diff --git a/tests/runner-e2e/remote-native-bootstrap.test.ts b/tests/runner-e2e/remote-native-bootstrap.test.ts new file mode 100644 index 00000000000..007957af88d --- /dev/null +++ b/tests/runner-e2e/remote-native-bootstrap.test.ts @@ -0,0 +1,410 @@ +import { explicitlyRequestsFileOutput } from "../../server/src/services/native-runtime/native-deliverable-feedback.js"; +import type { APIRequestContext, APIResponse } from "@playwright/test"; +import { afterEach, expect, it, vi } from "vitest"; +import { classifyFailure } from "./failure-classifier.js"; +import { ObservedStateTimeout, RemoteAdmissionReadError, RunnerApi, RunnerApiHttpError } from "./api.js"; +import { createRemoteNativeBootstrap } from "./remote-native-bootstrap.js"; +import { REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS, type RemoteFixtureApi, type RemoteNativeFixture } from "./remote-native-fixtures.js"; + +afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); }); + +function harness(timeoutMs = REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS + 18_000) { + const order: string[] = []; + const issue = { id: "issue", companyId: "company", assigneeAgentId: "agent" }; + const run = { id: "run", companyId: "company", agentId: "agent", status: "running", executionStage: "preparing" }; + const leases = [{ id: "lease", heartbeatRunId: "run", issueId: "issue", status: "active", providerLeaseId: "sandbox" }]; + const api = { get: vi.fn(async (path: string) => path === "/api/issues/issue" ? issue : path === "/api/heartbeat-runs/run" ? run : leases) }; + const fixture = { + binding: { companyId: "company", environmentId: "env", runId: "run", leaseId: "lease", sandboxId: "sandbox", remoteCwd: "/workspace" }, + baseline: { complete: true }, + publishAction: vi.fn(async () => { order.push("publish"); }), + close: vi.fn(async () => { order.push("close"); }), + } as unknown as RemoteNativeFixture; + const bind = vi.fn(async () => { order.push("armed"); return fixture; }); + const input = { + api: api as unknown as RemoteFixtureApi, daytona: { get: vi.fn() }, companyId: "company", environmentId: "env", agentId: "agent", + image: `image@sha256:${"a".repeat(64)}`, nodeSha256: `sha256:${"b".repeat(64)}`, runnerdSha256: `sha256:${"c".repeat(64)}`, + deadlineAt: Date.now() + timeoutMs, evidence: vi.fn(async (_name: string, _data: unknown) => { order.push("evidence"); }), + }; + const bootstrap = createRemoteNativeBootstrap(input, bind); + const request = { issueId: "issue", runId: "run", targets: ["target.txt"], actionPrompt: async (actual: RemoteNativeFixture) => { + expect(actual).toBe(fixture); await Promise.resolve(); order.push("baseline"); return "PRIVATE ACTUAL ACTION"; + } }; + return { bootstrap, input, bind, api, issue, run, leases, fixture, request, order }; +} + +it("keeps negated bootstrap instructions out of the production file-delivery contract", () => { + const prompt = harness().bootstrap.prompt("nonce"); + // Retained failed attempt: comma splitting detached this clause from “do not”. + const original = "Before reading those instructions, do not infer the task, run shell commands, create or modify any file, ask replacement questions, or mark work complete. Do not create the missing instruction file."; + expect(explicitlyRequestsFileOutput(original)).toBe(true); + expect(explicitlyRequestsFileOutput(prompt)).toBe(false); + expect(prompt).toContain("Do not create or modify any file."); + expect(prompt).toContain("Do not run shell commands."); + expect(prompt).toContain("Do not ask replacement questions."); + expect(prompt).toContain("Do not mark work complete."); + expect(prompt).toContain("Do not create the missing instruction file."); + // A real requested output still requires delivery; no production gate changes. + expect(explicitlyRequestsFileOutput(`${prompt}\nCreate a downloadable report.txt file.`)).toBe(true); +}); + + +it("keeps the complete readiness/install reserve when a lease arrives at the admission boundary", async () => { + vi.useFakeTimers(); vi.setSystemTime(0); + const h = harness(REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS + 1000); h.bootstrap.prompt("late-lease"); + const lease = h.leases.pop()!; + let remainingAtBind = 0; + h.bind.mockImplementation(async () => { remainingAtBind = h.input.deadlineAt - Date.now(); return h.fixture; }); + const pending = h.bootstrap.bindAndRelease(h.request); + await vi.advanceTimersByTimeAsync(800); + expect(h.bind).not.toHaveBeenCalled(); expect(h.fixture.publishAction).not.toHaveBeenCalled(); + h.leases.push(lease); await vi.advanceTimersByTimeAsync(100); + await expect(pending).resolves.toBe(h.fixture); + expect(remainingAtBind).toBeGreaterThanOrEqual(REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS); + expect(h.bind).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ deadlineAt: h.input.deadlineAt })); + expect(h.order.indexOf("baseline")).toBeLessThan(h.order.indexOf("publish")); +}); + +it("withholds actual work until exact run admission, armed observer and awaited baseline", async () => { + const h = harness(); const prompt = h.bootstrap.prompt("nonce"); + expect(prompt).not.toContain("PRIVATE ACTUAL ACTION"); + expect(prompt).toContain("native file-read tool"); + expect(h.bind).not.toHaveBeenCalled(); + expect(await h.bootstrap.bindAndRelease(h.request)).toBe(h.fixture); + expect(h.order).toEqual(["armed", "baseline", "evidence", "publish"]); + expect(h.bind).toHaveBeenCalledWith(expect.objectContaining({ + sdkVersion: "0.203.0", targets: ["target.txt"], + authority: { companyId: "company", environmentId: "env", runId: "run", leaseId: "lease", sandboxId: "sandbox", image: h.input.image }, + })); + expect(h.fixture.publishAction).toHaveBeenCalledWith(expect.stringMatching(/^\.paperclip-eval-action-[a-f0-9]{36}\.txt$/u), "PRIVATE ACTUAL ACTION"); + expect(JSON.stringify(h.input.evidence.mock.calls)).not.toContain("PRIVATE ACTUAL ACTION"); + await expect(h.bootstrap.bindAndRelease(h.request)).rejects.toThrow("one unconsumed"); +}); + +it.each(["issue-company", "issue-agent", "issue-id", "run-company", "run-agent", "run-id", "run-terminal", "ambiguous-lease"])("rejects %s before observer execution or action delivery", async variant => { + const h = harness(); h.bootstrap.prompt("nonce"); + if (variant === "issue-company") h.issue.companyId = "other"; + if (variant === "issue-agent") h.issue.assigneeAgentId = "other"; + if (variant === "issue-id") h.issue.id = "other"; + if (variant === "run-company") h.run.companyId = "other"; + if (variant === "run-agent") h.run.agentId = "other"; + if (variant === "run-id") h.run.id = "other"; + if (variant === "run-terminal") h.run.status = "succeeded"; + if (variant === "ambiguous-lease") h.leases.push({ ...h.leases[0]!, id: "second" }); + await expect(h.bootstrap.bindAndRelease(h.request)).rejects.toThrow(); + expect(h.bind).not.toHaveBeenCalled(); expect(h.fixture.publishAction).not.toHaveBeenCalled(); +}); + +it("waits through queued admission without publishing early", async () => { + const h = harness(); h.bootstrap.prompt("nonce"); h.run.status = "queued"; + const original = h.api.get.getMockImplementation()!; let count = 0; + h.api.get.mockImplementation(async path => { + if (path === "/api/heartbeat-runs/run" && ++count === 2) h.run.status = "running"; + return original(path); + }); + await h.bootstrap.bindAndRelease(h.request); + expect(count).toBe(2); expect(h.fixture.publishAction).toHaveBeenCalledTimes(1); +}); + +it.each(["", "x".repeat(16385)])("rejects empty or over-bound action after closing only its observer", async action => { + const h = harness(); h.bootstrap.prompt("nonce"); + await expect(h.bootstrap.bindAndRelease({ ...h.request, actionPrompt: () => action })).rejects.toThrow("empty or too large"); + expect(h.fixture.publishAction).not.toHaveBeenCalled(); expect(h.fixture.close).toHaveBeenCalledTimes(1); +}); + +it("retains uncertain delivery and cleanup failure without retrying publication", async () => { + const h = harness(); h.bootstrap.prompt("nonce"); + vi.mocked(h.fixture.publishAction).mockRejectedValue(new Error("uncertain delivery")); + vi.mocked(h.fixture.close).mockRejectedValue(new Error("cleanup failed")); + await expect(h.bootstrap.bindAndRelease(h.request)).rejects.toThrow("observer cleanup is unproven"); + await expect(h.bootstrap.bindAndRelease(h.request)).rejects.toThrow("one unconsumed"); + expect(h.fixture.publishAction).toHaveBeenCalledTimes(1); +}); + +it("rejects ambiguous bootstrap delivery and reused nonces", async () => { + const h = harness(); h.bootstrap.prompt("first"); + expect(() => h.bootstrap.prompt("first")).toThrow("reused"); h.bootstrap.prompt("second"); + await expect(h.bootstrap.bindAndRelease(h.request)).rejects.toThrow("one unconsumed"); + expect(h.api.get).not.toHaveBeenCalled(); +}); + +it.each(["nodeSha256", "runnerdSha256", "image"])("requires immutable %s before any remote call", field => { + const h = harness(); + expect(() => createRemoteNativeBootstrap({ ...h.input, [field]: "ambient-latest" }, h.bind)).toThrow("immutable"); + expect(h.input.daytona.get).not.toHaveBeenCalled(); +}); + + +it("admits a cold building_snapshot lease after 20 seconds within the unchanged case deadline", async () => { + vi.useFakeTimers(); vi.setSystemTime(0); + const h = harness(90_000); h.bootstrap.prompt("cold"); + const original = h.api.get.getMockImplementation()!; + let sandboxState = "building_snapshot"; + h.api.get.mockImplementation(async path => path.endsWith("/leases") && sandboxState === "building_snapshot" ? [] : original(path)); + const delivery = h.bootstrap.bindAndRelease(h.request); + await vi.advanceTimersByTimeAsync(25_000); + expect(h.bind).not.toHaveBeenCalled(); expect(h.fixture.publishAction).not.toHaveBeenCalled(); + sandboxState = "started"; + await vi.advanceTimersByTimeAsync(100); + await expect(delivery).resolves.toBe(h.fixture); + expect(h.bind).toHaveBeenCalledWith(expect.objectContaining({ deadlineAt: 90_000 })); + expect(h.input.daytona.get).not.toHaveBeenCalled(); +}); + +it.each(["failed", "cancelled", "succeeded"])("stops waiting immediately when provisioning run becomes %s", async status => { + vi.useFakeTimers(); vi.setSystemTime(0); + const h = harness(REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS + 18_000); h.bootstrap.prompt("terminal"); h.leases.length = 0; + const delivery = expect(h.bootstrap.bindAndRelease(h.request)).rejects.toThrow("stopped before observer setup"); + await vi.advanceTimersByTimeAsync(1000); h.run.status = status; + await vi.advanceTimersByTimeAsync(100); await delivery; + expect(Date.now()).toBeLessThan(h.input.deadlineAt); + expect(h.bind).not.toHaveBeenCalled(); expect(h.fixture.publishAction).not.toHaveBeenCalled(); + expect(h.input.evidence).toHaveBeenCalledWith("remote-native-bootstrap-startup-run.json", expect.objectContaining({ runStatus: status, deadlineReached: false })); +}); + +it("rechecks run ownership while waiting for the lease", async () => { + vi.useFakeTimers(); vi.setSystemTime(0); + const h = harness(); h.bootstrap.prompt("owner"); h.leases.length = 0; + const delivery = expect(h.bootstrap.bindAndRelease(h.request)).rejects.toThrow("ownership is unproven"); + await vi.advanceTimersByTimeAsync(100); h.run.companyId = "foreign"; + await vi.advanceTimersByTimeAsync(100); await delivery; + expect(h.bind).not.toHaveBeenCalled(); expect(h.fixture.publishAction).not.toHaveBeenCalled(); +}); + +it.each(["foreign-run", "foreign-issue", "inactive", "missing-provider"])("never admits %s and reserves setup time within the authored deadline with bounded state", async variant => { + vi.useFakeTimers(); vi.setSystemTime(0); + const h = harness(REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS + 1000); h.bootstrap.prompt("timeout"); + if (variant === "foreign-run") h.leases[0]!.heartbeatRunId = "foreign"; + if (variant === "foreign-issue") h.leases[0]!.issueId = "foreign"; + if (variant === "inactive") h.leases[0]!.status = "released"; + if (variant === "missing-provider") h.leases[0]!.providerLeaseId = ""; + h.run.executionStage = "PRIVATE".repeat(10_000); + const delivery = expect(h.bootstrap.bindAndRelease(h.request)).rejects.toThrow("Timed out waiting for owned native qualification lease"); + await vi.advanceTimersByTimeAsync(1000); await delivery; + expect(Date.now()).toBe(1000); expect(h.bind).not.toHaveBeenCalled(); + expect(h.fixture.publishAction).not.toHaveBeenCalled(); + const state = h.input.evidence.mock.calls[0]![1]; + expect(state).toMatchObject({ executionStage: "unknown", activeOwnedLeaseCount: 0, deadlineReached: false, admissionDeadlineReached: true }); + expect(Buffer.byteLength(JSON.stringify(state))).toBeLessThan(512); + expect(JSON.stringify(state)).not.toContain("PRIVATE"); +}); + +it("rejects a second same-run lease even if only one lease is active", async () => { + const h = harness(); h.bootstrap.prompt("ambiguous"); + h.leases.push({ ...h.leases[0]!, id: "old", status: "released" }); + await expect(h.bootstrap.bindAndRelease(h.request)).rejects.toThrow("Ambiguous native qualification lease"); + expect(h.bind).not.toHaveBeenCalled(); expect(h.fixture.publishAction).not.toHaveBeenCalled(); +}); + +it("does not start observation after the case deadline", async () => { + const h = harness(0); h.bootstrap.prompt("expired"); + await expect(h.bootstrap.bindAndRelease(h.request)).rejects.toThrow("Timed out waiting"); + expect(h.api.get).not.toHaveBeenCalled(); expect(h.bind).not.toHaveBeenCalled(); +}); + + +it.each(["terminal", "run-owner", "issue-owner"])("does not lose a successful %s read when the lease endpoint rejects", async variant => { + vi.useFakeTimers(); vi.setSystemTime(0); + const h = harness(); h.bootstrap.prompt("read-error"); + if (variant === "terminal") h.run.status = "failed"; + if (variant === "run-owner") h.run.companyId = "foreign"; + if (variant === "issue-owner") h.issue.companyId = "foreign"; + const original = h.api.get.getMockImplementation()!; + h.api.get.mockImplementation(async path => { + if (path.endsWith("/leases") || (variant === "issue-owner" && path.endsWith("/run")) + || (variant !== "issue-owner" && path.endsWith("/issue"))) throw new Error("PRIVATE API ERROR"); + return original(path); + }); + await expect(h.bootstrap.bindAndRelease(h.request)).rejects.toThrow(variant === "terminal" ? "stopped before observer setup" : "ownership is unproven"); + expect(Date.now()).toBe(0); expect(h.bind).not.toHaveBeenCalled(); + expect(h.input.evidence.mock.calls[0]![1]).toMatchObject({ admissionDeadlineReached: false }); + expect(JSON.stringify(h.input.evidence.mock.calls)).not.toContain("PRIVATE"); +}); + +it.each(["/api/issues/issue", "/api/heartbeat-runs/run", "/api/environments/env/leases"])("never admits while %s cannot be read", async failedPath => { + vi.useFakeTimers(); vi.setSystemTime(0); + const h = harness(REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS + 1000); h.bootstrap.prompt("missing-read"); + const original = h.api.get.getMockImplementation()!; + h.api.get.mockImplementation(async path => { if (path === failedPath) throw new Error("PRIVATE API ERROR"); return original(path); }); + const delivery = expect(h.bootstrap.bindAndRelease(h.request)).rejects.toThrow("diagnostics withheld"); + await vi.advanceTimersByTimeAsync(1000); await delivery; + expect(h.bind).not.toHaveBeenCalled(); expect(h.fixture.publishAction).not.toHaveBeenCalled(); + expect(JSON.stringify(h.input.evidence.mock.calls)).not.toContain("PRIVATE"); +}); + +it("allows a transient lease read failure to recover without losing the setup reserve", async () => { + vi.useFakeTimers(); vi.setSystemTime(0); + const h = harness(); h.bootstrap.prompt("recover-read"); + const original = h.api.get.getMockImplementation()!; let failed = false; + h.api.get.mockImplementation(async path => { if (path.endsWith("/leases") && !failed) { failed = true; throw new Error("unavailable"); } return original(path); }); + const delivery = h.bootstrap.bindAndRelease(h.request); + await vi.advanceTimersByTimeAsync(100); await expect(delivery).resolves.toBe(h.fixture); + expect(h.bind).toHaveBeenCalledTimes(1); +}); + +it("rejects a lease read completing inside the final setup reserve and saves startup evidence", async () => { + vi.useFakeTimers(); vi.setSystemTime(0); + const h = harness(REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS + 1000); h.bootstrap.prompt("late"); + const original = h.api.get.getMockImplementation()!; + h.api.get.mockImplementation(async path => { + if (path.endsWith("/leases")) await new Promise(resolve => setTimeout(resolve, 1100)); + return original(path); + }); + const delivery = expect(h.bootstrap.bindAndRelease(h.request)).rejects.toThrow("Timed out waiting"); + await vi.advanceTimersByTimeAsync(1200); await delivery; + expect(h.bind).not.toHaveBeenCalled(); expect(h.fixture.publishAction).not.toHaveBeenCalled(); + expect(h.input.evidence.mock.calls[0]![1]).toMatchObject({ phase: "lease_admission", leasesRead: "rejected", admissionDeadlineReached: true, deadlineReached: false }); + expect(h.input.evidence.mock.calls[0]![1]).not.toHaveProperty("activeOwnedLeaseCount"); +}); + +it("captures binder failure after admission without publishing or repeating setup", async () => { + const h = harness(); h.bootstrap.prompt("bind-failure"); + h.bind.mockRejectedValue(new Error("remote_native_fixture:insufficient_setup_budget")); + await expect(h.bootstrap.bindAndRelease(h.request)).rejects.toThrow("insufficient_setup_budget"); + expect(h.input.evidence.mock.calls[0]![1]).toMatchObject({ phase: "observer_setup", activeOwnedLeaseCount: 1 }); + expect(h.bind).toHaveBeenCalledTimes(1); expect(h.fixture.publishAction).not.toHaveBeenCalled(); +}); + + +it.each(["terminal", "run-owner", "issue-owner"])("rejects %s immediately with another failed read and a pending lease, and consumes its late rejection", async variant => { + vi.useFakeTimers(); vi.setSystemTime(0); + const h = harness(); h.bootstrap.prompt("slow-lease"); + if (variant === "terminal") h.run.status = "failed"; + if (variant === "run-owner") h.run.companyId = "foreign"; + if (variant === "issue-owner") h.issue.companyId = "foreign"; + let rejectLease!: (error: Error) => void; + const leaseRead = new Promise((_resolve, reject) => { rejectLease = reject; }); + const original = h.api.get.getMockImplementation()!; + h.api.get.mockImplementation(async path => { + if (path.endsWith("/leases")) return leaseRead; + if (path === (variant === "issue-owner" ? "/api/heartbeat-runs/run" : "/api/issues/issue")) throw new Error("503 PRIVATE"); + return original(path); + }); + await expect(h.bootstrap.bindAndRelease(h.request)).rejects.toThrow(variant === "terminal" ? "stopped before observer setup" : "ownership is unproven"); + expect(Date.now()).toBe(0); expect(h.api.get).toHaveBeenCalledTimes(3); + expect(h.api.get).toHaveBeenCalledWith("/api/environments/env/leases", { timeout: 18_000 }); + const saved = JSON.stringify(h.input.evidence.mock.calls); + expect(saved).toContain('"leasesRead":"pending"'); expect(saved).not.toContain("PRIVATE"); + rejectLease(new Error("late private rejection")); + await vi.advanceTimersByTimeAsync(60_000); + expect(JSON.stringify(h.input.evidence.mock.calls)).toBe(saved); + expect(h.api.get).toHaveBeenCalledTimes(3); expect(h.bind).not.toHaveBeenCalled(); +}); + +it("retains persistent 503 classification without its raw cause", async () => { + vi.useFakeTimers(); vi.setSystemTime(0); + const h = harness(REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS + 1000); h.bootstrap.prompt("503"); + const cause = new RunnerApiHttpError(503, "GET /api/environments/env/leases returned 503: PRIVATE BODY"); + const original = h.api.get.getMockImplementation()!; + h.api.get.mockImplementation(async path => { if (path.endsWith("/leases")) throw cause; return original(path); }); + const delivery = h.bootstrap.bindAndRelease(h.request).catch(error => error); + await vi.advanceTimersByTimeAsync(1000); const error = await delivery; + expect(error).toBeInstanceOf(ObservedStateTimeout); expect(error.cause).toBeUndefined(); + expect(classifyFailure(error)).toBe("transient_infrastructure"); + expect(error.message).not.toContain("PRIVATE"); + expect(h.input.evidence.mock.calls[0]![1]).toMatchObject({ readFailureClass: "transient_infrastructure" }); + expect(JSON.stringify(h.input.evidence.mock.calls)).not.toContain("PRIVATE"); + expect(h.bind).not.toHaveBeenCalled(); +}); + +it.each([true, false])("clears a recovered 503 cause before %s admission or observed-state timeout", async admits => { + vi.useFakeTimers(); vi.setSystemTime(0); + const h = harness(REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS + 1000); h.bootstrap.prompt("503-recovery"); + const original = h.api.get.getMockImplementation()!; let failed = false; + h.api.get.mockImplementation(async path => { + if (path.endsWith("/leases")) { + if (!failed) { failed = true; throw new RunnerApiHttpError(503, "PRIVATE BODY"); } + if (!admits) return []; + } + return original(path); + }); + const delivery = h.bootstrap.bindAndRelease(h.request).catch(error => error); + await vi.advanceTimersByTimeAsync(1000); const result = await delivery; + if (admits) expect(result).toBe(h.fixture); + else { expect(classifyFailure(result)).toBe("candidate_failure"); expect(result.cause).toBeUndefined(); } +}); + +it("bounds an unresponsive read at admission without launching replacement reads", async () => { + vi.useFakeTimers(); vi.setSystemTime(0); + const h = harness(REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS + 1000); h.bootstrap.prompt("hung"); + const original = h.api.get.getMockImplementation()!; + h.api.get.mockImplementation(async path => path.endsWith("/leases") ? new Promise(() => {}) : original(path)); + const delivery = h.bootstrap.bindAndRelease(h.request).catch(error => error); + await vi.advanceTimersByTimeAsync(1100); const error = await delivery; + expect(classifyFailure(error)).toBe("transient_infrastructure"); + expect(h.api.get).toHaveBeenCalledTimes(3); expect(h.bind).not.toHaveBeenCalled(); +}); + + +it.each([ + ["Playwright TimeoutError", Object.assign(new Error("Timeout 1000ms exceeded. PRIVATE"), { name: "TimeoutError" }), "transient_infrastructure"], + ["Playwright fetch timeout", new Error("apiRequestContext.get: Timeout 1000ms exceeded. PRIVATE"), "transient_infrastructure"], + ["server fetch timeout", new Error("Timeout 1000ms exceeded PRIVATE"), "transient_infrastructure"], + ["connection reset", new Error("apiRequestContext.get: read ECONNRESET PRIVATE"), "transient_infrastructure"], + ["socket hang up", new Error("socket hang up"), "transient_infrastructure"], + ["prefixed socket hang up", new Error("apiRequestContext.get: Socket Hang Up PRIVATE"), "transient_infrastructure"], + ["undefined", undefined, "candidate_failure"], + ["null", null, "candidate_failure"], + ["private string", "PRIVATE 503 TimeoutError", "candidate_failure"], + ["unknown object", { message: "PRIVATE", status: 503 }, "candidate_failure"], +])("normalizes %s rejection without exposing transport diagnostics", async (_label, rejected, failureClass) => { + vi.useFakeTimers(); vi.setSystemTime(0); + const h = harness(REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS + 1000); h.bootstrap.prompt("safe-errors"); + const original = h.api.get.getMockImplementation()!; + h.api.get.mockImplementation(async path => { if (path.endsWith("/leases")) throw rejected; return original(path); }); + const delivery = h.bootstrap.bindAndRelease(h.request).catch(error => error); + await vi.advanceTimersByTimeAsync(1000); const error = await delivery; + expect(classifyFailure(error)).toBe(failureClass); + expect(error.cause).toBeUndefined(); + expect(error.message.length).toBeLessThan(1024); + expect(error.stack).not.toContain("PRIVATE"); + expect(JSON.stringify(error)).not.toContain("PRIVATE"); + expect(JSON.stringify(h.input.evidence.mock.calls)).not.toContain("PRIVATE"); + expect(h.input.evidence.mock.calls[0]![1]).toMatchObject({ leasesRead: "rejected", readFailureClass: failureClass }); + expect(h.bind).not.toHaveBeenCalled(); +}); + +it.each([[503, "transient_infrastructure"], [403, "permanent_infrastructure"], [400, "candidate_failure"]])( + "normalizes actual RunnerApi HTTP %s status independently of its private body", async (status, failureClass) => { + vi.useFakeTimers(); vi.setSystemTime(0); + vi.stubEnv("PAPERCLIP_RUNNER_E2E_PORT", "3100"); + const h = harness(REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS + 1000); h.bootstrap.prompt("actual-api"); + const original = h.api.get.getMockImplementation()!; + const secret = "PRIVATE body: forbidden secret plaintext 503 timeout socket hang up ".repeat(10_000); + const request = { get: vi.fn(async (path: string) => ({ + ok: () => !path.endsWith("/leases"), status: () => status, + url: () => "http://PRIVATE.invalid/private", text: async () => secret, + json: async () => original(path), + } as APIResponse)) }; + const actual = new RunnerApi(request as unknown as APIRequestContext); + h.api.get.mockImplementation(path => actual.get(path, { timeout: 1000 })); + const delivery = h.bootstrap.bindAndRelease(h.request).catch(error => error); + await vi.advanceTimersByTimeAsync(1000); const error = await delivery; + expect(classifyFailure(error)).toBe(failureClass); + expect(error.cause).toBeUndefined(); + expect(error.message.length).toBeLessThan(1024); + expect(error.stack).not.toContain("PRIVATE"); + expect(JSON.stringify(error)).not.toContain("PRIVATE"); + expect(JSON.stringify(h.input.evidence.mock.calls)).not.toContain("PRIVATE"); + if (status === 403) expect(error).toBeInstanceOf(RemoteAdmissionReadError); + expect(h.bind).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); + }, +); + +it.each([ + ["issue", null], ["run", []], ["leases", {}], ["leases", [null]], + ["leases", ["PRIVATE"]], ["leases", [[{}]]], ["leases", undefined], +])("rejects malformed successful %s JSON without an unhandled continuation", async (endpoint, value) => { + vi.useFakeTimers(); vi.setSystemTime(0); + const h = harness(REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS + 1000); h.bootstrap.prompt("malformed"); + const original = h.api.get.getMockImplementation()!; + const path = endpoint === "issue" ? "/api/issues/issue" : endpoint === "run" ? "/api/heartbeat-runs/run" : "/api/environments/env/leases"; + h.api.get.mockImplementation(async requested => requested === path ? value as never : original(requested)); + const delivery = h.bootstrap.bindAndRelease(h.request).catch(error => error); + await vi.advanceTimersByTimeAsync(1000); const error = await delivery; + expect(error).toBeInstanceOf(RemoteAdmissionReadError); + expect(classifyFailure(error)).toBe("candidate_failure"); + expect(error.cause).toBeUndefined(); expect(error.stack).not.toContain("PRIVATE"); + expect(h.bind).not.toHaveBeenCalled(); +}); diff --git a/tests/runner-e2e/remote-native-bootstrap.ts b/tests/runner-e2e/remote-native-bootstrap.ts new file mode 100644 index 00000000000..c750ae29535 --- /dev/null +++ b/tests/runner-e2e/remote-native-bootstrap.ts @@ -0,0 +1,218 @@ +import { randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { ObservedStateTimeout, RemoteAdmissionReadError, RunnerApiHttpError, pollUntil } from "./api.js"; +import { classifyFailure } from "./failure-classifier.js"; +import { REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS, bindRemoteNativeFixture, remoteNativeFixtureDiagnostics, type RemoteFixtureApi, type RemoteFixtureDaytona, type RemoteNativeFixture } from "./remote-native-fixtures.js"; + +type AdmissionEndpoint = "issue" | "run" | "leases"; + +function admissionReadError(endpoint: AdmissionEndpoint, error: unknown): RemoteAdmissionReadError { + let failureClass: RemoteAdmissionReadError["failureClass"] = "candidate_failure"; + // Only typed status or transport diagnostics classify the failure. In + // particular, an HTTP body must not supply classification keywords. + try { + if (error instanceof RunnerApiHttpError) { + if (error.status === 408 || error.status === 429 || (error.status >= 500 && error.status <= 599)) failureClass = "transient_infrastructure"; + else if (error.status === 401 || error.status === 403) failureClass = "permanent_infrastructure"; + } else if (error instanceof Error) { + const message = error.message.slice(0, 256); + if (error.name === "TimeoutError" || /^(?:apiRequestContext\.get: )?Timeout \d+ms exceeded\b/u.test(message) + || /\b(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up)\b/iu.test(message)) failureClass = "transient_infrastructure"; + } + } catch { /* Unknown rejection values remain a bounded candidate failure. */ } + return new RemoteAdmissionReadError(endpoint, failureClass); +} + +function admissionJson(endpoint: AdmissionEndpoint, value: unknown): boolean { + const record = (item: unknown): item is Record => item !== null && typeof item === "object" && !Array.isArray(item); + return endpoint === "leases" ? Array.isArray(value) && value.every(record) : record(value); +} + +export interface RemoteNativeBootstrap { + prompt(nonce: string): string; + bindAndRelease(input: { + issueId: string; runId: string; targets: readonly string[]; crossRoot?: { initialText: string }; + actionPrompt(fixture: RemoteNativeFixture): string | Promise; + }): Promise; +} + +/** Use the repository's pinned SDK, never an ambient CLI, SDK or credential. */ +export async function createRemoteFixtureClient(apiKey: string): Promise { + if (!apiKey.trim()) throw new Error("Remote native qualification requires an explicitly bound Daytona credential"); + const require = createRequire(new URL("../../packages/plugins/sandbox-providers/daytona/package.json", import.meta.url)); + const entry = require.resolve("@daytonaio/sdk"); + let directory = dirname(entry), version: string | undefined; + for (let i = 0; i < 4; i++, directory = dirname(directory)) { + try { + const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); + if (pkg.name === "@daytonaio/sdk") { version = pkg.version; break; } + } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + } + if (version !== "0.203.0") throw new Error("Remote native qualification requires Daytona SDK 0.203.0"); + const sdk = await import(pathToFileURL(entry).href); + return new sdk.Daytona({ apiKey, apiUrl: "https://app.daytona.io/api" }) as RemoteFixtureDaytona; +} + +/** The tested action is withheld until its observer is armed. The initial + * provider prompt only reads this one operator-published instruction file. + * No production hook, steering support or remote setup-command execution is + * assumed. Bootstrap reads are distinct from the native action under test. */ +export function createRemoteNativeBootstrap(input: { + api: RemoteFixtureApi; daytona: RemoteFixtureDaytona; companyId: string; environmentId: string; + agentId: string; image: string; nodeSha256: string; runnerdSha256: string; deadlineAt: number; + evidence(name: string, data: unknown): Promise; +}, bind = bindRemoteNativeFixture): RemoteNativeBootstrap { + if (!/^sha256:[a-f0-9]{64}$/u.test(input.nodeSha256) || !/^sha256:[a-f0-9]{64}$/u.test(input.runnerdSha256) || !/@sha256:[a-f0-9]{64}$/u.test(input.image)) { + throw new Error("Remote native qualification requires immutable image and interpreter digests"); + } + const bindings = new Map(); + return { + prompt(nonce) { + if (!/^[A-Za-z0-9_-]{1,128}$/u.test(nonce) || bindings.has(nonce)) throw new Error("Invalid or reused remote bootstrap nonce"); + const path = `.paperclip-eval-action-${randomBytes(18).toString("hex")}.txt`; + bindings.set(nonce, { path, consumed: false }); + return [ + `Your task instructions will be published by the operator in the workspace file ${path}.`, + "Use your native file-read tool to read that exact relative file in the current execution workspace. If it is not present yet, retry the read for up to 20 seconds, then report the setup failure.", + "Before reading those instructions: do not infer the task. Do not run shell commands. Do not create or modify any file.", + "Do not ask replacement questions. Do not mark work complete. Do not create the missing instruction file.", + "After reading the complete file, perform precisely the supplied task. Treat it as the operator's continuation of this task.", + ].join("\n"); + }, + async bindAndRelease(request) { + const pending = [...bindings.values()].filter(value => !value.consumed); + if (pending.length !== 1) throw new Error("Remote bootstrap requires one unconsumed instruction delivery"); + const setup = pending[0]!; setup.consumed = true; + if (![request.issueId, request.runId].every(id => /^[A-Za-z0-9_-]{1,128}$/u.test(id))) throw new Error("Invalid bootstrap task/run identity"); + // Provisioning consumes the authored case deadline. A cold snapshot need + // not produce a lease in 20 seconds; a terminal or foreign run never waits. + let lastState: Record = { observed: false }; + const admissionDeadlineAt = input.deadlineAt - REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS; + let fixture: RemoteNativeFixture; + let lastReadError: RemoteAdmissionReadError | undefined; + try { + const ready = await pollUntil({ + label: "owned native qualification lease", deadlineAt: admissionDeadlineAt, intervalMs: 100, + load: async () => { + type Read = { status: "fulfilled"; value: T } | { status: "rejected"; reason: RemoteAdmissionReadError } | { status: "pending" }; + let issueRead: Read> = { status: "pending" }; + let runRead: Read> = { status: "pending" }; + let leasesRead: Read>> = { status: "pending" }; + const snapshot = () => { + const issue = issueRead.status === "fulfilled" ? issueRead.value : undefined; + const run = runRead.status === "fulfilled" ? runRead.value : undefined; + const rows = leasesRead.status === "fulfilled" ? leasesRead.value : undefined; + const issueOwned = issue?.id === request.issueId && issue.companyId === input.companyId + && issue.assigneeAgentId === input.agentId; + const runOwned = run?.companyId === input.companyId && run.agentId === input.agentId && run.id === request.runId; + const owned = issueOwned && runOwned; + const runLeases = Array.isArray(rows) ? rows.filter(row => row?.heartbeatRunId === request.runId) : undefined; + const active = runLeases?.filter(row => row.issueId === request.issueId && row.status === "active" && row.providerLeaseId); + // A failed endpoint cannot discard a successful read proving that + // ownership changed or the run stopped. Missing reads never admit. + const rejection = (issueRead.status === "fulfilled" && !issueOwned) || (runRead.status === "fulfilled" && !runOwned) + ? "Remote bootstrap task/run ownership is unproven" + : runRead.status === "fulfilled" && !["queued", "running"].includes(run?.status) ? "Native qualification run stopped before observer setup" + : runLeases && runLeases.length > 1 ? "Ambiguous native qualification lease" : undefined; + // Fixed keys and allowlisted enum values only: no provider IDs, + // errors, prompts or arbitrary API strings enter startup evidence. + const readError = [issueRead, runRead, leasesRead].find(read => read.status === "rejected"); + const evidence = { + observed: true, owned, phase: "lease_admission", + issueRead: issueRead.status, runRead: runRead.status, leasesRead: leasesRead.status, + runStatus: ["queued", "running", "succeeded", "failed", "cancelled", "timed_out"].includes(run?.status) ? run!.status : "unknown", + executionStage: ["queued", "preparing", "executing", "finalizing"].includes(run?.executionStage) ? run!.executionStage : "unknown", + ...(runLeases ? { runLeaseCount: runLeases.length, activeOwnedLeaseCount: active!.length } : {}), + ...(readError?.status === "rejected" ? { readFailureClass: classifyFailure(readError.reason) } : {}), + deadlineReached: Date.now() >= input.deadlineAt, + }; + return { run, owned, lease: active?.[0], rejection, evidence, + readError: readError?.status === "rejected" ? readError.reason : undefined }; + }; + // At most three in-flight reads. Each has a transport deadline no + // later than admission; terminal/ownership proof need not await it. + const remainingMs = Math.max(1, admissionDeadlineAt - Date.now()); + const timeout = Math.min(30_000, remainingMs); + const state = await new Promise>(resolve => { + let finished = false; + const finish = (state: ReturnType) => { + if (finished) return; + finished = true; clearTimeout(timer); resolve(state); + }; + const timer = setTimeout(() => { + const failure = (endpoint: AdmissionEndpoint) => ({ status: "rejected" as const, reason: new RemoteAdmissionReadError(endpoint, "transient_infrastructure") }); + if (issueRead.status === "pending") issueRead = failure("issue"); + if (runRead.status === "pending") runRead = failure("run"); + if (leasesRead.status === "pending") leasesRead = failure("leases"); + finish(snapshot()); + }, remainingMs); + function read(endpoint: AdmissionEndpoint, path: string, save: (result: Read) => void) { + // Both handlers are installed before dispatch. Late completion + // is consumed but cannot mutate saved evidence or start a poll. + void Promise.resolve().then(() => input.api.get(path, { timeout })).then( + value => settled(admissionJson(endpoint, value) ? { status: "fulfilled", value } + : { status: "rejected", reason: admissionReadError(endpoint, undefined) }), + reason => settled({ status: "rejected", reason: admissionReadError(endpoint, reason) }), + ); + function settled(result: Read) { + if (finished) return; + save(result); + const state = snapshot(); + if (state.rejection || [issueRead, runRead, leasesRead].every(read => read.status !== "pending")) finish(state); + } + } + read>("issue", `/api/issues/${request.issueId}`, result => { issueRead = result; }); + read>("run", `/api/heartbeat-runs/${request.runId}`, result => { runRead = result; }); + read>>("leases", `/api/environments/${input.environmentId}/leases`, result => { leasesRead = result; }); + }); + lastState = state.evidence; + lastReadError = state.rejection ? undefined : state.readError; + if (lastReadError !== undefined) throw lastReadError; + return state; + }, + accept: state => !state.rejection && state.owned && state.run?.status === "running" && Boolean(state.lease) + && Date.now() < admissionDeadlineAt, + reject: state => state.rejection, + timeoutDetail: () => JSON.stringify(lastState), + }); + const lease = ready.lease!; + lastState = { ...lastState, phase: "observer_setup" }; + fixture = await bind({ api: input.api, daytona: input.daytona, sdkVersion: "0.203.0", nodeSha256: input.nodeSha256, runnerdSha256: input.runnerdSha256, + authority: { companyId: input.companyId, environmentId: input.environmentId, runId: request.runId, leaseId: lease.id, sandboxId: lease.providerLeaseId, image: input.image }, + targets: [...request.targets], crossRoot: request.crossRoot, actionFile: setup.path, deadlineAt: input.deadlineAt, + }); + } catch (error) { + if (lastReadError !== undefined && lastState.phase === "lease_admission") { + if (classifyFailure(lastReadError) === "transient_infrastructure") { + const timeout = new ObservedStateTimeout("owned native qualification lease", "transient_infrastructure", JSON.stringify(lastState)); + // The safe read classification is already in the bounded state. + // Do not expose the rejected transport value through a cause chain. + error = timeout; + } else error = lastReadError; + } + try { + await input.evidence(`remote-native-bootstrap-startup-${request.runId}.json`, { + ...lastState, fixtureDiagnostics: remoteNativeFixtureDiagnostics(error), deadlineReached: Date.now() >= input.deadlineAt, + admissionDeadlineReached: Date.now() >= admissionDeadlineAt, + }); + } catch (evidenceError) { + throw new AggregateError([error, evidenceError], "Remote bootstrap startup failed and state evidence could not be saved"); + } + throw error; + } + try { + const action = await request.actionPrompt(fixture); + if (!action.trim() || Buffer.byteLength(action) > 16 * 1024) throw new Error("Remote bootstrap action is empty or too large"); + await input.evidence(`remote-native-bootstrap-${request.runId}.json`, { binding: fixture.binding, setupPath: setup.path, baseline: fixture.baseline }); + await fixture.publishAction(setup.path, action); + return fixture; + } catch (error) { + try { await fixture.close(); } catch { throw new AggregateError([error], "Remote bootstrap failed and observer cleanup is unproven"); } + throw error; + } + }, + }; +} diff --git a/tests/runner-e2e/remote-native-fixtures.test.ts b/tests/runner-e2e/remote-native-fixtures.test.ts new file mode 100644 index 00000000000..a3ca84018c2 --- /dev/null +++ b/tests/runner-e2e/remote-native-fixtures.test.ts @@ -0,0 +1,694 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; +import { EventEmitter } from "node:events"; +import { mkdtemp, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Script } from "node:vm"; +import { describe, expect, it, vi } from "vitest"; +import { REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS, bindRemoteNativeFixture, createRemoteTargetWatch, isRemoteRunRoot, parseRemoteProcStat, remoteNativeFixtureDiagnostics, type RemoteNativeFixtureOptions, type RemoteNativeSnapshot } from "./remote-native-fixtures.js"; + +import { createRemoteNativeBootstrap } from "./remote-native-bootstrap.js"; + +const hash = (s: string) => `sha256:${createHash("sha256").update(s).digest("hex")}`; +const bootId = "12345678-1234-1234-1234-123456789abc"; +const authority = { companyId: "company", environmentId: "environment", runId: "run", leaseId: "lease", sandboxId: "sandbox", image: `registry/image@sha256:${"a".repeat(64)}` }; +const binding = { ...authority, remoteCwd: "/workspace" }; +const root = { pid: 21, ppid: 1, startTicks: "100", bootId }; +function snapshot(): RemoteNativeSnapshot { + return { binding, observedAtMs: 1000, observedMonotonicNs: "10000", receivedAtMs: 1000, complete: true, workspace: { "existing.txt": hash("original") }, + targets: { "result.txt": { absent: true, sha256: null, parent: { dev: "1", ino: "2" }, mutationCount: 0, complete: true } }, + watcher: { complete: true, targetMutationCount: 0, workspaceMutationCount: 0 }, + processes: { captured: true, root, journal: [root], live: [21] }, scope: { kind: "user_workspace", excludedRuntime: { relativePath: ".paperclip-runtime/paperclip-runner", absolutePath: "/workspace/.paperclip-runtime/paperclip-runner", dev: "1", ino: "4", runnerExecutableSha256: hash("runnerd") }, observedPrpEnvironmentLeaseId: "workspace-id", prpEnvironmentLeaseIdVerified: false }, setup: { path: "action.txt", sha256: null, published: false }, attached: null }; +} +function readiness() { return { ready: true, binding, root, runtime: { dev: "1", ino: "4", runnerExecutableSha256: hash("runnerd") } }; } +function harness() { + let lease: Record = { id: "lease", companyId: "company", environmentId: "environment", heartbeatRunId: "run", provider: "daytona", providerLeaseId: "sandbox", status: "active", releasedAt: null, + metadata: { sandboxId: "sandbox", image: authority.image, reuseLease: false, remoteCwd: "/workspace", workspaceSentinel: { path: "/workspace/.paperclip-runtime/reusable-sandbox-lease.json", token: "fixture-sentinel-token", result: "written", runId: "run", providerLeaseId: "sandbox" } } }; + const labels: Record = { "paperclip-provider": "daytona", "paperclip-company-id": "company", "paperclip-environment-id": "environment", "paperclip-run-id": "run", "paperclip-reuse-lease": "false" }; + const calls: Array<{ command: string; request: Record; timeout: number | undefined }> = []; + let resolveTerminal!: (v: unknown) => void, rejectTerminal!: (e: unknown) => void; + const terminal = new Promise((resolve, reject) => { resolveTerminal = resolve; rejectTerminal = reject; }); + const current = snapshot(); + let override: ((request: Record) => unknown) | undefined; + const executeCommand = vi.fn(async (command: string, _cwd?: string, _env?: Record, timeout?: number) => { + const encoded = command.match(/ '([A-Za-z0-9+/=]+)'$/u)?.[1]; + if (!encoded) throw new Error("invalid command"); + const request = JSON.parse(Buffer.from(encoded, "base64").toString()); calls.push({ command, request, timeout }); + if (request.op === "wait") return { exitCode: 0, result: JSON.stringify({ ok: true, result: await terminal }) }; + if (override) { const result = override(request); if (result !== undefined) return result as { exitCode: number; result: string }; } + let result: unknown = request.op === "runtime-ready" ? readiness() : structuredClone(current); + if (request.op === "publish") { current.setup = { path: request.path, published: true, sha256: hash(request.text) }; result = current.setup; } + if (request.op === "close") result = { closed: true }; + if (request.op === "arm") result = { armed: true, sealed: false }; + if (request.op === "attached") result = { clientScript: `${request.root}/client.cjs`, clientSocket: `${request.root}/attached.sock` }; + return { exitCode: 0, result: JSON.stringify({ ok: true, result }) }; + }); + const get = vi.fn(async () => ({ id: "sandbox", labels, process: { executeCommand } })); + const apiGet = vi.fn(async (path: string) => path.includes("/environments/") ? [structuredClone(lease)] : structuredClone(lease)); + const options: RemoteNativeFixtureOptions = { api: { get: apiGet as RemoteNativeFixtureOptions["api"]["get"] }, daytona: { get }, sdkVersion: "0.203.0", authority, nodeSha256: hash("node"), runnerdSha256: hash("runnerd"), targets: ["result.txt"], actionFile: "action.txt", deadlineAt: Date.now() + REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS + 18_000 }; + return { options, current, labels, calls, executeCommand, apiGet, get, resolveTerminal, rejectTerminal, + setLease(value: Record) { lease = value; }, lease: () => lease, override(fn: typeof override) { override = fn; } }; +} + +describe("remote native lease admission", () => { + it.each(["companyId", "environmentId", "heartbeatRunId", "providerLeaseId", "provider", "status"])("rejects wrong %s before executing any remote command", async key => { + const h = harness(); h.setLease({ ...h.lease(), [key]: "foreign" }); + await expect(bindRemoteNativeFixture(h.options)).rejects.toThrow("lease_scope"); expect(h.executeCommand).not.toHaveBeenCalled(); + }); + it("rejects protected runtime targets and insufficient setup budget before SDK access", async () => { + for (const patch of [{ targets: [".paperclip-runtime/paperclip-runner/bin/forbidden"] }, { actionFile: ".paperclip-runtime/paperclip-runner/task.txt" }, { deadlineAt: Date.now() + 1000 }]) { + const h = harness(); await expect(bindRemoteNativeFixture({ ...h.options, ...patch })).rejects.toThrow(); expect(h.get).not.toHaveBeenCalled(); + } + }); + it("requires exact SDK, immutable image and executable hashes", async () => { + for (const input of [{ sdkVersion: "0.204.0" }, { nodeSha256: "unknown" }, { authority: { ...authority, image: "image:latest" } }]) { + const h = harness(); await expect(bindRemoteNativeFixture({ ...h.options, ...input } as RemoteNativeFixtureOptions)).rejects.toThrow(); expect(h.get).not.toHaveBeenCalled(); + } + }); + it("rejects reuse, foreign metadata and sentinel binding", async () => { + for (const patch of [{ reuseLease: true }, { sandboxId: "other" }, { image: "image:latest" }, { remoteCwd: "/workspace/../foreign" }, { workspaceSentinel: { token: "fixture-sentinel-token" } }]) { + const h = harness(); h.setLease({ ...h.lease(), metadata: { ...(h.lease().metadata as object), ...patch } }); + await expect(bindRemoteNativeFixture(h.options)).rejects.toThrow(); expect(h.executeCommand).not.toHaveBeenCalled(); + } + }); + it("rejects wrong ownership labels and never discovers a sandbox by name", async () => { + const h = harness(); h.labels["paperclip-run-id"] = "other"; + await expect(bindRemoteNativeFixture(h.options)).rejects.toThrow("sandbox_labels"); expect(h.get).toHaveBeenCalledExactlyOnceWith("sandbox"); expect(h.executeCommand).not.toHaveBeenCalled(); + }); + it("waits beyond the old 20s install subdeadline for staging, then installs exactly once before action", async () => { + vi.useFakeTimers(); vi.setSystemTime(1_000_000); + try { + const h = harness(); h.options.deadlineAt += 60_000; + h.override(r => r.op === "runtime-ready" && Date.now() < 1_021_000 ? { exitCode: 0, result: JSON.stringify({ ok: true, result: { ready: false } }) } : undefined); + const pending = bindRemoteNativeFixture(h.options); + await vi.advanceTimersByTimeAsync(20_000); + expect(h.calls.every(c => c.request.op === "runtime-ready")).toBe(true); + await vi.advanceTimersByTimeAsync(1000); const fixture = await pending; + expect(h.calls.filter(c => c.request.op === "install")).toHaveLength(1); + expect(h.calls.find(c => c.request.op === "install")!.timeout).toBe(25); + expect(h.calls.at(-1)!.request.op).toBe("arm"); + expect(h.apiGet).toHaveBeenCalledTimes((h.calls.filter(c => c.request.op === "runtime-ready").length + 2) * 2); + await fixture.close(); + } finally { vi.useRealTimers(); } + }); + it("reserves readiness after a late lease with delayed admission and installation", async () => { + vi.useFakeTimers(); vi.setSystemTime(1_000_000); + try { + const h = harness(); h.options.deadlineAt = Date.now() + REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS; + const initialGet = h.get.getMockImplementation()!; + h.get.mockImplementationOnce(async () => { await new Promise(resolve => setTimeout(resolve, 9000)); return initialGet(); }); + h.override(r => { + if (r.op === "runtime-ready" || r.op === "install") return new Promise(resolve => setTimeout(() => resolve({ + exitCode: 0, result: JSON.stringify({ ok: true, result: r.op === "runtime-ready" ? readiness() : snapshot() }), + }), r.op === "runtime-ready" ? 9000 : 24_000)); + return undefined; + }); + const pending = bindRemoteNativeFixture(h.options); + // Retain a rejected setup for the assertion below while fake clocks advance. + void pending.catch(() => {}); + await vi.advanceTimersByTimeAsync(8999); expect(h.calls).toHaveLength(0); + await vi.advanceTimersByTimeAsync(9001); expect(h.calls.map(c => c.request.op)).toEqual(["runtime-ready", "install"]); + expect(h.calls.find(c => c.request.op === "install")!.timeout).toBe(25); + await vi.advanceTimersByTimeAsync(24_000); const fixture = await pending; + expect(h.calls.map(c => c.request.op)).toEqual(["runtime-ready", "install", "wait", "arm"]); + expect(h.options.deadlineAt - Date.now()).toBe(22_000); + await fixture.publishAction("action.txt", "task"); expect(h.calls.at(-1)!.request.op).toBe("publish"); + await fixture.close(); + } finally { vi.useRealTimers(); } + }); + it.each([42_000, 43_000, REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS - 1])("rejects a %ims late setup window before remote work", async budget => { + vi.useFakeTimers(); vi.setSystemTime(1_000_000); + try { + const h = harness(); h.options.deadlineAt = Date.now() + budget; + await expect(bindRemoteNativeFixture(h.options)).rejects.toThrow("insufficient_setup_budget"); + expect(h.apiGet).not.toHaveBeenCalled(); expect(h.get).not.toHaveBeenCalled(); expect(h.calls).toHaveLength(0); + } finally { vi.useRealTimers(); } + }); + it("never installs on a missing runtime and reserves installation and teardown inside the original deadline", async () => { + vi.useFakeTimers(); vi.setSystemTime(1_000_000); + try { + const h = harness(); h.override(r => r.op === "runtime-ready" ? { exitCode: 0, result: '{"ok":true,"result":{"ready":false}}' } : undefined); + const outcome = bindRemoteNativeFixture(h.options).catch(error => error); + await vi.advanceTimersByTimeAsync(h.options.deadlineAt - 42_000 - Date.now()); + expect((await outcome).message).toContain("readiness_deadline"); + expect(h.calls.every(c => c.request.op === "runtime-ready")).toBe(true); + expect(Date.now()).toBe(h.options.deadlineAt - 42_000); + } finally { vi.useRealTimers(); } + }); + it.each(["lease", "binding", "shape", "pin", "unknown-error"])("rejects %s during readiness without installing or publishing", async variant => { + const h = harness(); + h.override(r => { + if (r.op !== "runtime-ready") return undefined; + if (variant === "lease") { h.setLease({ ...h.lease(), heartbeatRunId: "other" }); return { exitCode: 0, result: '{"ok":true,"result":{"ready":false}}' }; } + if (variant === "unknown-error") return { exitCode: 2, result: 'SECRET sdk stderr' }; + const value: any = readiness(); + if (variant === "binding") value.binding = { ...binding, leaseId: "foreign" }; + if (variant === "pin") value.runtime.runnerExecutableSha256 = hash("foreign"); + if (variant === "shape") value.extra = "secret"; + return { exitCode: 0, result: JSON.stringify({ ok: true, result: value }) }; + }); + const error = await bindRemoteNativeFixture(h.options).catch(error => error); + expect(error).toBeInstanceOf(Error); + expect(h.calls.every(c => c.request.op === "runtime-ready")).toBe(true); + expect(JSON.stringify(remoteNativeFixtureDiagnostics(error))).not.toContain("SECRET"); + }); + it.each(["extra", "foreign-phase", "unknown-code", "malformed"])("drops %s remote diagnostic content without retry", async variant => { + const h = harness(); + h.override(r => r.op === "runtime-ready" ? { exitCode: 2, result: variant === "malformed" ? "PRIVATE stderr" : JSON.stringify({ ok: false, diagnostic: { phase: variant === "foreign-phase" ? "close" : r.op, code: variant === "unknown-code" ? "PRIVATE stderr" : "socket_error", ...(variant === "extra" ? { private: "PRIVATE stderr" } : {}) } }) } : undefined); + const error = await bindRemoteNativeFixture(h.options).catch(error => error); + expect(remoteNativeFixtureDiagnostics(error)).toEqual([{ phase: "runtime-ready", code: "invalid_response" }]); + expect(error.message).not.toContain("PRIVATE"); expect(h.calls).toHaveLength(1); + }); + it("persists safe startup diagnostics through the real bootstrap catch without publishing an action", async () => { + const h = harness(), evidence = vi.fn(async () => {}); + h.override(r => r.op === "runtime-ready" ? { exitCode: 2, result: JSON.stringify({ ok: false, diagnostic: { phase: "runtime-ready", code: "runtime_binary_identity" } }) } : undefined); + const bootstrap = createRemoteNativeBootstrap({ ...h.options, companyId: "company", environmentId: "environment", agentId: "agent", image: authority.image, evidence, + api: { get: (async (path: string) => path === "/api/issues/issue" ? { id: "issue", companyId: "company", assigneeAgentId: "agent" } + : path === "/api/heartbeat-runs/run" ? { id: "run", companyId: "company", agentId: "agent", status: "running", executionStage: "preparing" } + : [{ ...h.lease(), issueId: "issue" }]) as RemoteNativeFixtureOptions["api"]["get"] }, + }, async options => bindRemoteNativeFixture({ ...h.options, actionFile: options.actionFile })); + bootstrap.prompt("nonce"); + const actionPrompt = vi.fn(async () => "PRIVATE ACTION"); + await expect(bootstrap.bindAndRelease({ issueId: "issue", runId: "run", targets: ["result.txt"], actionPrompt })).rejects.toThrow("runtime_binary_identity"); + expect(actionPrompt).not.toHaveBeenCalled(); + expect(evidence).toHaveBeenCalledWith("remote-native-bootstrap-startup-run.json", expect.objectContaining({ + phase: "observer_setup", fixtureDiagnostics: [{ phase: "runtime-ready", code: "runtime_binary_identity" }], + })); + expect(JSON.stringify(evidence.mock.calls)).not.toContain("PRIVATE ACTION"); + expect(h.calls.map(c => c.request.op)).toEqual(["runtime-ready"]); + }); + it.each(["pid", "ppid", "startTicks", "bootId"])("rejects changed %s between readiness and baseline despite extra observer process fields", async key => { + const h = harness(); + const changed = { state: "S", group: 21, ...root, [key]: key === "bootId" ? "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" : key === "startTicks" ? "999" : 99 }; + h.current.processes = { captured: true, root: changed, journal: [changed], live: [changed.pid] }; + await expect(bindRemoteNativeFixture(h.options)).rejects.toThrow("runtime_identity_changed"); + expect(h.calls.map(c => c.request.op)).toEqual(["runtime-ready", "install", "close"]); + }); + it("retains the closed read phase on transport errors without leaking text", async () => { + const h = harness(), f = await bindRemoteNativeFixture(h.options); + h.override(r => r.op === "read" ? { exitCode: 2, result: JSON.stringify({ ok: false, diagnostic: { phase: "read", code: "socket_error" } }) } : undefined); + const error = await f.readFile("result.txt").catch(error => error); + expect(remoteNativeFixtureDiagnostics(error)).toEqual([{ phase: "read", code: "socket_error" }]); + await f.close(); + }); + it("retains only allowlisted install and cleanup diagnostics with no uncertain retry", async () => { + const h = harness(); + h.override(r => ["install", "close"].includes(r.op) ? { exitCode: 2, result: JSON.stringify({ ok: false, diagnostic: { phase: r.op, code: r.op === "install" ? "runtime_identity_changed" : "socket_error" } }) } : undefined); + const error = await bindRemoteNativeFixture(h.options).catch(error => error); + expect(error.message).toContain("startup_failed_cleanup_unproven"); + expect(remoteNativeFixtureDiagnostics(error)).toEqual([{ phase: "close", code: "socket_error" }, { phase: "install", code: "runtime_identity_changed" }]); + expect(h.calls.map(c => c.request.op)).toEqual(["runtime-ready", "install", "close"]); + }); + it("arms before publish, binds long receipt before teardown and preserves exact final bytes", async () => { + const h = harness(), f = await bindRemoteNativeFixture(h.options); + expect(h.calls.map(c => c.request.op)).toEqual(["runtime-ready", "install", "wait", "arm"]); expect(f.baseline.processes.live).toEqual([21]); + expect(h.calls[2]!.timeout).toBeLessThanOrEqual(67); expect(h.calls[2]!.request.timeoutMs).toBe(h.calls[2]!.timeout! * 1000); + await f.publishAction("action.txt", "write only result.txt"); + await expect(f.publishAction("action.txt", "retry")).rejects.toThrow("publish_bound"); + const bytes = "\nUnicode 🪴 literal \\n\n"; + const final = { ...structuredClone(h.current), processes: { ...h.current.processes, live: [] }, files: { "result.txt": Buffer.from(bytes).toString("base64") } }; + final.targets["result.txt"] = { ...final.targets["result.txt"]!, absent: false, sha256: hash(bytes), mutationCount: 1 }; + h.resolveTerminal(final); h.apiGet.mockRejectedValue(new Error("lease already deleted")); + expect((await f.finish()).processes.live).toEqual([]); expect((await f.readFile("result.txt")).toString()).toBe(bytes); + await f.close(); expect(h.calls.map(c => c.request.op)).toEqual(["runtime-ready", "install", "wait", "arm", "publish"]); + }); + it("fails closed when lease deletion beats the terminal receipt", async () => { + const h = harness(), f = await bindRemoteNativeFixture(h.options); await f.publishAction("action.txt", "task"); + h.rejectTerminal(new Error("channel closed before receipt")); await expect(f.finish()).rejects.toThrow("remote_command_failed_or_deadline"); + }); + it("rejects lease rotation before subsequent commands", async () => { + const h = harness(), f = await bindRemoteNativeFixture(h.options); const count = h.calls.length; + h.setLease({ ...h.lease(), heartbeatRunId: "next-run" }); + await expect(f.snapshot("pending")).rejects.toThrow("lease_scope"); expect(h.calls).toHaveLength(count); + }); + it.each(["live", "incomplete", "missing-root", "bad-file"])("rejects %s terminal proof without weakening assertions", async type => { + const h = harness(), f = await bindRemoteNativeFixture(h.options); await f.publishAction("action.txt", "task"); + const end: any = { ...structuredClone(h.current), processes: { ...h.current.processes, live: [] }, files: {} }; + if (type === "live") end.processes.live = [21]; + if (type === "incomplete") end.watcher.complete = false; + if (type === "missing-root") end.processes = { captured: false, root: null, journal: [], live: [] }; + if (type === "bad-file") end.targets["result.txt"] = { ...end.targets["result.txt"], absent: false, sha256: hash("missing") }; + h.resolveTerminal(end); await expect(f.finish()).rejects.toThrow(); + }); + it("keeps a fixture-owned cross-root sentinel distinct from workspace targets", async () => { + const h = harness(); h.options.crossRoot = { initialText: "outside sentinel" }; + h.current.targets["@cross-root"] = { absent: false, sha256: hash("outside sentinel"), parent: { dev: "1", ino: "outside" }, mutationCount: 0, complete: true }; + h.current.targets["@cross-root"]!.parent.ino = "42"; + const f = await bindRemoteNativeFixture(h.options); + expect(f.outsideTarget).toMatch(/^\/tmp\/pc-native-[a-f0-9]{36}\/cross-root-target$/u); + expect(f.outsideTarget!.startsWith(f.remoteCwd + "/")).toBe(false); + expect(f.baseline.targets["@cross-root"]!.sha256).toBe(hash("outside sentinel")); + }); + it("cleans only its admitted observer on a failed startup receipt and never publishes", async () => { + const h = harness(); h.current.processes = { captured: false, root: null, journal: [], live: [] }; + await expect(bindRemoteNativeFixture(h.options)).rejects.toThrow("bootstrap_not_held"); + expect(h.calls.map(c => c.request.op)).toEqual(["runtime-ready", "install", "close"]); + }); + it("refuses action publication without a confirmed receipt channel", async () => { + const h = harness(); h.override(r => r.op === "arm" ? { exitCode: 0, result: JSON.stringify({ ok: true, result: { armed: false, sealed: false } }) } : undefined); + await expect(bindRemoteNativeFixture(h.options)).rejects.toThrow("receipt_channel_not_armed"); + expect(h.calls.map(c => c.request.op)).toEqual(["runtime-ready", "install", "wait", "arm", "close"]); + }); + it("bounds malformed command output and does not replay an uncertain publish", async () => { + const h = harness(), f = await bindRemoteNativeFixture(h.options); + h.override(r => r.op === "publish" ? { exitCode: 0, result: "x".repeat(262145) } : undefined); + await expect(f.publishAction("action.txt", "task")).rejects.toThrow("output_bound"); + await expect(f.publishAction("action.txt", "task")).rejects.toThrow("publish_bound"); + expect(h.calls.filter(c => c.request.op === "publish")).toHaveLength(1); + }); + it("ships syntactically valid closed Node programs with exact binary and no provider env", async () => { + const h = harness(); await bindRemoteNativeFixture(h.options); + const install = h.calls.find(c => c.request.op === "install")!; const source = install.request.source as string; + expect(() => new Script(source)).not.toThrow(); expect(source).not.toContain("__name("); + const rpcQuoted = install.command.match(/ -e (.+) '[A-Za-z0-9+/=]+'$/su)![1]!; + const rpc = rpcQuoted.slice(1, -1).replaceAll("'\\''", "'"); + expect(() => new Script(rpc)).not.toThrow(); + expect(rpc).not.toContain("startedAt+20000"); expect(install.timeout).toBeLessThanOrEqual(27); expect(rpc).toContain("r.timeoutMs-(Date.now()-startedAt)"); + expect(source).toContain("/proc/"); expect(source).toContain("workspaceWatch"); expect(source).toContain("finalReceipt.files"); + expect(install.command).toMatch(/^\/usr\/bin\/env -i PATH=\/usr\/bin:\/bin /u); + expect(install.request.config.runnerdSha256).toBe(hash("runnerd")); + expect(source).not.toMatch(/execSync|execFileSync/u); + }); +}); + +describe("cell deadline and cleanup bounds", () => { + it("expires finish with 15s reserved, bounds SDK/socket/observer clocks, and handles late SDK failure", async () => { + vi.useFakeTimers(); vi.setSystemTime(1_000_000); + try { + const h = harness(), f = await bindRemoteNativeFixture(h.options); + await f.publishAction("action.txt", "task"); + const install = h.calls.find(c => c.request.op === "install")!, wait = h.calls.find(c => c.request.op === "wait")!; + expect(wait.timeout).toBe(67); expect(wait.request.timeoutMs).toBe(67_000); + expect(install.request.config.observerTtlMs).toBe(67_000); expect(install.timeout).toBe(25); + let settled = false; + const result = f.finish().then(() => { settled = true; return "unexpected pass"; }, error => { settled = true; return error.message; }); + await vi.advanceTimersByTimeAsync(66_999); expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); expect(await result).toContain("deadline"); + expect(Date.now()).toBe(h.options.deadlineAt - 15_000); + h.rejectTerminal(new Error("late SDK close after host deadline")); await Promise.resolve(); + await f.close(); expect(h.calls.at(-1)!.request.op).toBe("close"); expect(h.calls.at(-1)!.timeout).toBe(10); + } finally { vi.useRealTimers(); } + }); + it("shortens a late ordinary RPC and never gives it a fresh timeout window", async () => { + vi.useFakeTimers(); vi.setSystemTime(1_000_000); + try { + const h = harness(), f = await bindRemoteNativeFixture(h.options); + await vi.advanceTimersByTimeAsync(64_000); + await f.snapshot("late-but-bounded"); const call = h.calls.at(-1)!; + expect(call.timeout).toBe(3); expect(call.request.timeoutMs).toBe(3000); + await vi.advanceTimersByTimeAsync(3000); + const count = h.calls.length; await expect(f.snapshot("too-late")).rejects.toThrow("receipt_deadline"); expect(h.calls).toHaveLength(count); + await f.close(); h.rejectTerminal(new Error("late SDK rejection")); await Promise.resolve(); + } finally { vi.useRealTimers(); } + }); + it("bounds cleanup after cell expiry and rejects finish immediately after explicit close", async () => { + vi.useFakeTimers(); vi.setSystemTime(1_000_000); + try { + const h = harness(), f = await bindRemoteNativeFixture(h.options); await vi.advanceTimersByTimeAsync(82_000); + h.override(r => r.op === "close" ? new Promise(() => {}) : undefined); + const close = f.close().then(() => "unexpected", error => error.message); + await vi.advanceTimersByTimeAsync(9999); expect(h.calls.at(-1)!.request.timeoutMs).toBe(10_000); + await vi.advanceTimersByTimeAsync(1); expect(await close).toContain("deadline"); + await expect(f.finish()).rejects.toThrow("deadline"); h.rejectTerminal(new Error("late")); await Promise.resolve(); + const h2 = harness(), f2 = await bindRemoteNativeFixture(h2.options); + await f2.close(); await expect(f2.finish()).rejects.toThrow("closed_before_receipt"); + h2.rejectTerminal(new Error("ordinary close ended socket")); await Promise.resolve(); + } finally { vi.useRealTimers(); } + }); +}); + +describe("independent filesystem and process observations", () => { + it.skipIf(process.platform !== "linux")("retains transient create/delete events and detects same-path parent replacement", async () => { + const dir = await mkdtemp(join(tmpdir(), "remote-watch-")); const watcher = createRemoteTargetWatch(dir, "denied.txt"); + try { + await writeFile(join(dir, "denied.txt"), "forbidden"); await rm(join(dir, "denied.txt")); + await vi.waitFor(() => expect(watcher.snapshot().mutationCount).toBeGreaterThan(0)); + expect(watcher.snapshot().complete).toBe(true); + await rename(dir, `${dir}-old`); await writeFile(dir, "replacement"); + expect(watcher.snapshot().complete).toBe(false); + } finally { watcher.close(); await rm(dir, { recursive: true, force: true }); await rm(`${dir}-old`, { recursive: true, force: true }); } + }); + it("marks lost filenames/watch errors incomplete", () => { + let callback!: (_kind: string, filename: string | null) => void; + const emitter = Object.assign(new EventEmitter(), { close: vi.fn() }); + const stat = { isDirectory: () => true, isSymbolicLink: () => false, dev: 1n, ino: 2n, mtimeNs: 3n, ctimeNs: 4n }; + const watcher = createRemoteTargetWatch("/test", "file", { watch: ((_path: unknown, cb: typeof callback) => { callback = cb; return emitter; }) as any, lstatSync: (() => stat) as any }); + callback("rename", null); expect(watcher.snapshot().complete).toBe(false); watcher.close(); + }); + it("parses Linux start ticks around unusual comm names and excludes shared/wrong-run daemons", () => { + const fields = ["S", "1", "21", ...Array(16).fill("0"), "123456"]; + const p = parseRemoteProcStat(21, `21 (name with ) parens) ${fields.join(" ")}`, bootId); + expect(p.startTicks).toBe("123456"); expect(isRemoteRunRoot(["/opt/paperclip-runnerd", "--run-id", "run", "--lifecycle-mode", "per_turn"], "run", p)).toBe(true); + expect(isRemoteRunRoot(["/opt/paperclip-runnerd", "--run-id", "foreign", "--lifecycle-mode", "per_turn"], "run", p)).toBe(false); + expect(isRemoteRunRoot(["/opt/paperclip-runnerd", "--run-id", "run", "--lifecycle-mode", "persistent"], "run", p)).toBe(false); + expect(isRemoteRunRoot(["/opt/paperclip-runnerd", "--run-id", "run", "--lifecycle-mode", "per_turn"], "run", { ...p, group: 1 })).toBe(false); + expect(parseRemoteProcStat(21, `21 (reused) ${fields.slice(0, -1).join(" ")} 999999`, bootId).startTicks).not.toBe(p.startTicks); + }); +}); + +describe("actual generated observer state machine", () => { + async function observerHarness(deferStartup = false, transformed?: { command: string; observer: string }) { + const h = harness(); await bindRemoteNativeFixture(h.options); + const install = h.calls.find(c => c.request.op === "install")!; + if (transformed) { + install.request.source = transformed.observer; + install.command = transformed.command; + } + const { source, config } = install.request; + const intervals: Array<() => void> = [], timers: Array<{ fn: () => void; ms: number }> = []; + const proc = new Map([[21, { ppid: 1, group: 21, ticks: "100", argv: ["/workspace/.paperclip-runtime/paperclip-runner/bin/paperclip-runnerd", "--run-id", "run", "--environment-lease-id", "workspace-id", "--lifecycle-mode", "per_turn", "--state-dir", "/workspace/.paperclip-runtime/paperclip-runner/sessions/" + "a".repeat(64) + "/runner"] }]]); + const files = new Map([[`${config.root}/observer.cjs`, Buffer.from(source)], [config.sentinel.path, Buffer.from(JSON.stringify({ version: 1, provider: "daytona", token: config.sentinel.token, companyId: "company", environmentId: "environment" }))]]); + const watches: Array<{ path: string; callback: (_kind: string, name: string | null) => void; closed: boolean }> = []; + const handlers: Array<(socket: any) => void> = [], children: any[] = []; + const missing = () => { throw Object.assign(new Error("missing"), { code: "ENOENT" }); }; + const fds = new Map(); let nextFd = 50, runtimeInode = 4n; + const symbolicLinks = new Set(); + const directories = new Set(["/tmp", "/workspace", "/workspace/.paperclip-runtime", "/workspace/.paperclip-runtime/paperclip-runner", "/workspace/.paperclip-runtime/paperclip-runner/sessions"]); + if (!deferStartup) directories.add(config.root); + const listeners = new Map void>(); + const fs = { + constants: { O_RDONLY: 0, O_NOFOLLOW: 131072 }, + openSync(path: string, flags: number) { expect(flags).toBe(131072); if (!files.has(path)) return missing(); const fd = nextFd++; fds.set(fd, path); return fd; }, + closeSync(fd: number) { fds.delete(fd); }, + fstatSync(fd: number) { const value = fs.lstatSync(fds.get(fd)!); return { ...value, size: BigInt(value.size) }; }, + readFileSync(path: string | number, encoding?: string) { + if (typeof path === "number") path = fds.get(path)!; + let value: Buffer | undefined; + if (path === "/proc/sys/kernel/random/boot_id") value = Buffer.from(bootId); + else if (path.startsWith("/proc/")) { + const [, , raw, field] = path.split("/"), p = proc.get(Number(raw)); if (!p) return missing(); + if (field === "stat") value = Buffer.from(`${raw} (runner) S ${p.ppid} ${p.group} ${Array(16).fill("0").join(" ")} ${p.ticks}`); + if (field === "cmdline") value = Buffer.from(p.argv.join("\0") + "\0"); + if (field === "exe") value = Buffer.from("runnerd"); + } else value = files.get(path); + if (!value) return missing(); return encoding ? value.toString() : value; + }, + lstatSync(path: string) { + const directory = directories.has(path); + if (!directory && !files.has(path) && !symbolicLinks.has(path)) return missing(); + return { dev: 1n, ino: path === config.root ? 2n : path === "/workspace/.paperclip-runtime/paperclip-runner" ? runtimeInode : 3n, mtimeNs: 4n, ctimeNs: 5n, isDirectory: () => directory, isFile: () => !directory, isSymbolicLink: () => symbolicLinks.has(path), size: files.get(path)?.length ?? 0 }; + }, + realpathSync: (path: string) => path, + readdirSync(path: string) { if (path === "/proc") return [...proc.keys()].map(String); if (path === "/workspace") return [".paperclip-runtime", ...[...files.keys(), ...symbolicLinks].filter(p => p.startsWith("/workspace/") && !p.slice(11).includes("/")).map(p => p.slice(11))]; if (path === "/workspace/.paperclip-runtime") return ["reusable-sandbox-lease.json", "paperclip-runner", ...[...files.keys()].filter(p => p.startsWith(path + "/") && !p.slice(path.length + 1).includes("/") && !p.endsWith("reusable-sandbox-lease.json")).map(p => p.slice(path.length + 1))]; if (path.startsWith("/workspace/.paperclip-runtime/paperclip-runner")) throw new Error("excluded runtime must not be traversed"); return []; }, + watch(path: string, options: unknown, callback?: (_kind: string, name: string | null) => void) { + const entry = { path, callback: (callback ?? options) as (_kind: string, name: string | null) => void, closed: false }; watches.push(entry); + return Object.assign(new EventEmitter(), { close: () => { entry.closed = true; } }); + }, + mkdirSync: vi.fn((path: string) => { + if (!directories.has(path.slice(0, path.lastIndexOf("/")))) return missing(); + if (directories.has(path) || files.has(path)) throw new Error("EEXIST"); + directories.add(path); + }), + existsSync: (path: string) => directories.has(path) || files.has(path) || listeners.has(path), + writeFileSync(path: string, content: string, opts: { flag: string }) { + if (!directories.has(path.slice(0, path.lastIndexOf("/")))) return missing(); + if (opts.flag === "wx" && files.has(path)) throw new Error("EEXIST"); files.set(path, Buffer.from(content)); + for (const w of watches) if (!w.closed && path.slice(0, path.lastIndexOf("/")) === w.path) w.callback("rename", path.slice(w.path.length + 1)); + }, + rmSync: vi.fn(), + }; + const server = { + listen: vi.fn((path: string) => { + if (!directories.has(path.slice(0, path.lastIndexOf("/")))) return missing(); + if (listeners.has(path)) throw new Error("EADDRINUSE"); + listeners.set(path, handlers.at(-1)!); + }), + close: vi.fn(() => listeners.clear()), + }; + const net = { createServer(fn: (socket: any) => void) { handlers.push(fn); return server; } }; + const context = { + require(name: string) { if (name === "node:fs") return fs; if (name === "node:net") return net; if (name === "node:child_process") return { spawn: vi.fn(() => { const child = Object.assign(new EventEmitter(), { pid: 88, exitCode: null, signalCode: null, kill: vi.fn() }); children.push(child); return child; }) }; if (name === "node:path") return { join: (...paths: string[]) => paths.join("/"), dirname: (path: string) => path.slice(0, path.lastIndexOf("/")), basename: (path: string) => path.slice(path.lastIndexOf("/") + 1) }; if (name === "node:crypto") return { createHash }; throw new Error("unexpected module"); }, + process: { argv: ["node", `${config.root}/observer.cjs`, Buffer.from(JSON.stringify(config)).toString("base64")], execPath: "/node", hrtime: { bigint: () => 12345n }, exit: vi.fn() }, + Buffer, __filename: `${config.root}/observer.cjs`, + setInterval(fn: () => void) { intervals.push(fn); return 1; }, clearInterval: vi.fn(), + setTimeout(fn: () => void, ms: number) { timers.push({ fn, ms }); return { unref() {} }; }, + }; + if (deferStartup) files.delete(`${config.root}/observer.cjs`); + else new Script(source).runInNewContext(context); + function request(op: string, args: Record = {}) { + const replies: any[] = [], socket = Object.assign(new EventEmitter(), { end: (value: string) => replies.push(JSON.parse(value)), destroy: vi.fn() }); + handlers[0]!(socket); socket.emit("data", Buffer.from(JSON.stringify({ op, nonce: config.nonce, ...args }) + "\n")); return replies; + } + return { request, proc, files, watches, fs, intervals, timers, config, handlers, children, symbolicLinks, context, server, directories, listeners, install: h.calls.find(c => c.request.op === "install")!, replaceRuntimeRoot() { runtimeInode = 999n; } }; + } + async function generatedRpc(o: Awaited>, request: Record, mutateSource = (source: string) => source) { + const quoted = o.install.command.match(/ -e (.+) '[A-Za-z0-9+/=]+'$/su)![1]!; + const source = quoted.slice(1, -1).replaceAll("'\\''", "'"); + const forwarded: string[] = [], connections: Array<{ client: any; observer: any }> = []; + let output = ""; + const process = { argv: ["node", Buffer.from(JSON.stringify(request)).toString("base64")], execPath: "/node", exitCode: 0, exit: vi.fn(), stdout: { write: (value: string) => { output += value; } } }; + const spawn = vi.fn((_node: string, argv: string[]) => { + expect(argv[0]).toBe(`${o.config.root}/observer.cjs`); + // Execute the actual installed observer, using its actual serialized config. + o.context.process.argv = ["node", ...argv]; + new Script(o.files.get(argv[0]!)!.toString()).runInNewContext(o.context); + return { unref: vi.fn() }; + }); + const fs = { ...o.fs, + readFileSync(path: string, encoding?: string) { return path === "/node" ? Buffer.from("node") : o.fs.readFileSync(path, encoding); } }; + const net = { connect(path: string) { + expect(path).toBe(`${o.config.root}/control.sock`); + const client: any = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() }); + const handler = o.listeners.get(path); + if (!handler) { queueMicrotask(() => client.emit("error", new Error("ECONNREFUSED"))); return client; } + const observer: any = Object.assign(new EventEmitter(), { + end(value: string) { client.emit("data", Buffer.from(value)); client.emit("end"); observer.emit("close"); }, + destroy: vi.fn(() => { client.emit("error", new Error("observer closed socket")); observer.emit("close"); }), + }); + client.write = (value: string) => { forwarded.push(value); observer.emit("data", Buffer.from(value)); }; + connections.push({ client, observer }); handler(observer); + queueMicrotask(() => client.emit("connect")); return client; + } }; + new Script(mutateSource(source)).runInNewContext({ require(name: string) { + if (name === "node:fs") return fs; + if (name === "node:net") return net; + if (name === "node:child_process") return { spawn }; + if (name === "node:crypto") return { createHash }; + throw new Error("unexpected RPC module"); + }, process, Buffer, setTimeout: (fn: () => void, ms: number) => { + // Advance only the bounded readiness polls; retain watchdogs as inert VM timers. + if (ms === 10 || ms === 50) queueMicrotask(fn); + return { unref() {} }; + } }); + await new Promise(resolve => setImmediate(resolve)); + return { output, process, forwarded, spawn, connections }; + } + it("executes readiness and installation after the actual pinned tsx production transform", async () => { + const modulePath = fileURLToPath(new URL("./remote-native-fixtures.ts", import.meta.url)); + const loaderPath = fileURLToPath(new URL("../../cli/node_modules/tsx/dist/loader.mjs", import.meta.url)); + const script = ` + import { bindRemoteNativeFixture } from ${JSON.stringify(modulePath)}; + const binding = ${JSON.stringify(binding)}, ready = ${JSON.stringify(readiness())}; + const lease = { id: binding.leaseId, companyId: binding.companyId, environmentId: binding.environmentId, heartbeatRunId: binding.runId, + provider: 'daytona', providerLeaseId: binding.sandboxId, status: 'active', releasedAt: null, + metadata: { sandboxId: binding.sandboxId, image: binding.image, reuseLease: false, remoteCwd: binding.remoteCwd, + workspaceSentinel: { path: binding.remoteCwd+'/.paperclip-runtime/reusable-sandbox-lease.json', token: 'fixture-sentinel-token', result: 'written', runId: binding.runId, providerLeaseId: binding.sandboxId } } }; + let programs; + const sandbox = { id: binding.sandboxId, labels: { 'paperclip-provider':'daytona','paperclip-company-id':binding.companyId,'paperclip-environment-id':binding.environmentId,'paperclip-run-id':binding.runId,'paperclip-reuse-lease':'false' }, + process: { async executeCommand(command) { + const encoded = command.slice(command.lastIndexOf(" '")+2,-1), request = JSON.parse(Buffer.from(encoded,'base64')); + if(request.op==='runtime-ready') return {exitCode:0,result:JSON.stringify({ok:true,result:ready})}; + if(request.op==='install') programs = { command, observer:request.source }; + return {exitCode:0,result:JSON.stringify({ok:true,result:request.op==='close'?{closed:true}:{}})}; + } } }; + try { await bindRemoteNativeFixture({ authority: ${JSON.stringify(authority)}, api:{get:async p=>p.includes('/environments/')?[lease]:lease}, daytona:{get:async()=>sandbox}, + sdkVersion:'0.203.0',nodeSha256:${JSON.stringify(hash("node"))},runnerdSha256:${JSON.stringify(hash("runnerd"))},targets:['result.txt'],actionFile:'action.txt',deadlineAt:Date.now()+82000 }); } catch {} + if(!programs) throw Error('No captured generated programs'); + process.stdout.write(JSON.stringify(programs)); + `; + const transformed = JSON.parse(execFileSync(process.execPath, ["--import", loaderPath, "--input-type=module", "-e", script], { + env: { PATH: "/usr/bin:/bin", HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, TSX_DISABLE_CACHE: "1" }, timeout: 10_000, maxBuffer: 256 * 1024, encoding: "utf8", + })); + expect(transformed.command).not.toContain("__name("); expect(transformed.observer).not.toContain("__name("); + const o = await observerHarness(true, transformed); + const ready = await generatedRpc(o, { ...o.install.request, op: "runtime-ready" }); + expect(JSON.parse(ready.output)).toEqual({ ok: true, result: readiness() }); + const installed = await generatedRpc(o, o.install.request); + expect(installed.process.exitCode).toBe(0); expect(JSON.parse(installed.output).result.complete).toBe(true); + const closed = await generatedRpc(o, { op: "close", root: o.config.root, nonce: o.config.nonce, nodeSha256: hash("node"), timeoutMs: 10_000 }); + expect(JSON.parse(closed.output)).toEqual({ ok: true, result: { closed: true } }); + o.timers.find(t => t.ms === 100)!.fn(); expect(o.watches.every(w => w.closed)).toBe(true); + }); + it("executes the actual readiness probe without creating an observer, then revalidates its exact identity at install", async () => { + const o = await observerHarness(true); + const ready = await generatedRpc(o, { ...o.install.request, op: "runtime-ready" }); + expect(JSON.parse(ready.output)).toEqual({ ok: true, result: readiness() }); + expect(ready.spawn).not.toHaveBeenCalled(); expect(o.fs.mkdirSync).not.toHaveBeenCalled(); expect(o.watches).toHaveLength(0); + o.replaceRuntimeRoot(); + const changed = await generatedRpc(o, o.install.request); + expect(JSON.parse(changed.output).diagnostic.code).toBe("runtime_identity_changed"); + expect(changed.spawn).not.toHaveBeenCalled(); expect(o.fs.mkdirSync).not.toHaveBeenCalled(); + }); + it.each(["absent", "foreign-run", "foreign-group", "ambiguous", "wrong-binary", "symlink", "disappeared"])("checks generated runtime readiness: %s", async variant => { + const o = await observerHarness(true), runtimePath = "/workspace/.paperclip-runtime/paperclip-runner"; + if (variant === "absent") o.directories.delete(runtimePath); + if (variant === "foreign-run") o.proc.get(21)!.argv[2] = "foreign"; + if (variant === "foreign-group") o.proc.get(21)!.group = 99; + if (variant === "ambiguous") o.proc.set(22, { ...o.proc.get(21)!, group: 22 }); + if (variant === "symlink") o.symbolicLinks.add(runtimePath); + if (variant === "disappeared") o.proc.clear(); + const request = { ...o.install.request, op: variant === "disappeared" ? "install" : "runtime-ready", config: { ...o.config, ...(variant === "wrong-binary" ? { runnerdSha256: hash("wrong") } : {}) } }; + const result = await generatedRpc(o, request), parsed = JSON.parse(result.output); + if (["absent", "foreign-run", "foreign-group"].includes(variant)) expect(parsed).toEqual({ ok: true, result: { ready: false } }); + else expect(parsed).toEqual({ ok: false, diagnostic: { phase: request.op, code: { ambiguous: "ambiguous_run_root", "wrong-binary": "runtime_binary_identity", symlink: "runtime_root_identity", disappeared: "runtime_not_ready" }[variant] } }); + expect(result.spawn).not.toHaveBeenCalled(); expect(o.fs.mkdirSync).not.toHaveBeenCalled(); + }); + it("uses the closed read phase for actual generated socket errors", async () => { + const o = await observerHarness(true); + const read = await generatedRpc(o, { op: "read", root: o.config.root, nonce: o.config.nonce, nodeSha256: hash("node"), timeoutMs: 10_000, path: "result.txt" }); + expect(JSON.parse(read.output)).toEqual({ ok: false, diagnostic: { phase: "read", code: "socket_error" } }); + expect(read.spawn).not.toHaveBeenCalled(); + }); + it("executes generated install through the bounded observer socket, then closes the exact observer", async () => { + const o = await observerHarness(true); + expect(Buffer.byteLength(o.install.request.source)).toBeGreaterThan(8192); + const installed = await generatedRpc(o, o.install.request); + expect(installed.process.exitCode).toBe(0); + const baseline = JSON.parse(installed.output); + expect(baseline.ok).toBe(true); expect(baseline.result.complete).toBe(true); + expect(baseline.result.processes.root).toMatchObject({ ...root, group: 21, state: "S" }); + // Feed the actual generated observer receipt through the real host reader, + // including parseRemoteProcStat's extra fields and different key order. + const h = harness(); h.override(request => request.op === "install" ? { exitCode: 0, result: installed.output } : undefined); + const fixture = await bindRemoteNativeFixture(h.options); + expect(fixture.baseline.processes.root).toEqual(baseline.result.processes.root); + await fixture.close(); + expect(installed.spawn).toHaveBeenCalledTimes(1); + expect(installed.forwarded.map(value => JSON.parse(value))).toEqual([{ op: "snapshot", nonce: o.config.nonce }]); + expect(Buffer.byteLength(installed.forwarded[0]!)).toBeLessThan(8192); + const closed = await generatedRpc(o, { op: "close", root: o.config.root, nonce: o.config.nonce, nodeSha256: hash("node"), timeoutMs: 10_000 }); + expect(closed.process.exitCode).toBe(0); expect(JSON.parse(closed.output)).toEqual({ ok: true, result: { closed: true } }); + expect(closed.spawn).not.toHaveBeenCalled(); + expect(closed.forwarded[0]).not.toContain('"source"'); expect(closed.forwarded[0]).not.toContain('"config"'); + expect(o.watches.length).toBeGreaterThan(0); expect(o.watches.every(w => w.closed)).toBe(true); + expect(o.context.clearInterval).toHaveBeenCalled(); expect(o.server.close).toHaveBeenCalled(); + o.timers.find(t => t.ms === 100)!.fn(); + expect(closed.connections[0]!.observer.destroy).toHaveBeenCalled(); + expect(o.fs.rmSync).toHaveBeenCalledExactlyOnceWith(o.config.root, { recursive: true, force: true }); + expect(o.context.process.exit).toHaveBeenCalledExactlyOnceWith(0); + }); + it("rejects a generated install mutant that omits its parent directory creation", async () => { + const o = await observerHarness(true); + const installed = await generatedRpc(o, o.install.request, source => { + const mutant = source.replace("fs.mkdirSync(c.root,{mode:0o700});", ""); + expect(mutant).not.toBe(source); return mutant; + }); + expect(installed.process.exitCode).toBe(2); expect(JSON.parse(installed.output).diagnostic).toEqual({ phase: "install", code: "remote_unknown" }); + expect(installed.spawn).not.toHaveBeenCalled(); expect(installed.connections).toHaveLength(0); + expect(o.files.has(`${o.config.root}/observer.cjs`)).toBe(false); + expect(o.directories.has(o.config.root)).toBe(false); expect(o.watches).toHaveLength(0); + }); + it("rejects a generated observer mutant that never listens, even after handler registration", async () => { + const o = await observerHarness(true); + const source = o.install.request.source as string; + const mutant = source.replace("server.listen(path.join(config.root,'control.sock'));", ""); + expect(mutant).not.toBe(source); + const installed = await generatedRpc(o, { ...o.install.request, source: mutant }); + expect(installed.process.exitCode).toBe(2); expect(JSON.parse(installed.output).diagnostic).toEqual({ phase: "install", code: "socket_error" }); + expect(installed.spawn).toHaveBeenCalledTimes(1); expect(o.handlers).toHaveLength(1); + expect(o.server.listen).not.toHaveBeenCalled(); expect(o.listeners.size).toBe(0); + expect(installed.connections).toHaveLength(0); expect(installed.forwarded).toHaveLength(0); + // A failed connection cannot close the unlistening observer. Its owned TTL still retires it. + o.timers.find(t => t.ms > 1000)!.fn(); o.timers.find(t => t.ms === 100)!.fn(); + expect(o.watches.every(w => w.closed)).toBe(true); + expect(o.fs.rmSync).toHaveBeenCalledExactlyOnceWith(o.config.root, { recursive: true, force: true }); + expect(o.context.process.exit).toHaveBeenCalledExactlyOnceWith(0); + }); + it("still rejects an oversized control request and can close its observer afterward", async () => { + const o = await observerHarness(); + const oversized = await generatedRpc(o, { op: "snapshot", root: o.config.root, nonce: o.config.nonce, nodeSha256: hash("node"), timeoutMs: 10_000, unexpected: "x".repeat(8192) }); + expect(oversized.process.exitCode).toBe(2); expect(JSON.parse(oversized.output).diagnostic).toEqual({ phase: "snapshot", code: "socket_error" }); + expect(oversized.connections[0]!.observer.destroy).toHaveBeenCalled(); + const closed = await generatedRpc(o, { op: "close", root: o.config.root, nonce: o.config.nonce, nodeSha256: hash("node"), timeoutMs: 10_000 }); + expect(JSON.parse(closed.output)).toEqual({ ok: true, result: { closed: true } }); + o.timers.find(t => t.ms === 100)!.fn(); + expect(o.watches.every(w => w.closed)).toBe(true); expect(o.context.process.exit).toHaveBeenCalledExactlyOnceWith(0); + }); + it("acknowledges receipt-channel readiness only after the long waiter connects", async () => { + const o = await observerHarness(); const arm = o.request("arm"); expect(arm).toHaveLength(0); + o.request("wait"); expect(arm).toEqual([{ ok: true, result: { armed: true, sealed: false } }]); + o.proc.set(1, { ppid: 0, group: 1, ticks: "1", argv: ["/sbin/init"] }); + expect(o.request("snapshot")[0].result.processes.captured).toBe(true); + }); + it("arms exact remote root then seals drained no-live proof and retained bytes", async () => { + const o = await observerHarness(); expect(o.request("snapshot")[0].result.processes.captured).toBe(true); + const wait = o.request("wait"); expect(wait).toHaveLength(0); + expect(o.request("publish", { path: "action.txt", text: "do task" })[0].ok).toBe(true); + o.fs.writeFileSync("/workspace/result.txt", "exact 🪴\n", { flag: "wx" }); + o.proc.set(22, { ppid: 21, group: 21, ticks: "200", argv: ["/node"] }); o.intervals[0]!(); + o.proc.clear(); o.intervals[0]!(); expect(wait).toHaveLength(0); // Watch drain is mandatory. + o.timers.find(t => t.ms === 100)!.fn(); + const final = wait[0].result; + expect(final.complete).toBe(true); expect(final.processes.live).toEqual([]); expect(final.processes.journal.map((p: any) => p.pid)).toEqual([21, 22]); + expect(Buffer.from(final.files["result.txt"], "base64").toString()).toBe("exact 🪴\n"); + expect(final.watcher.workspaceMutationCount).toBe(1); expect(final.workspace["action.txt"]).toBeUndefined(); expect(final.setup.sha256).toBe(hash("do task")); + expect(o.watches.every(w => w.closed)).toBe(true); + }); + it("links the attached client to the exact run and writes the marker only after independent child exit", async () => { + const o = await observerHarness(); o.request("snapshot"); + const config = { marker: "result.txt", markerText: "settled", delayMs: 500, clientNonce: "test-client-nonce" }; + const fixture = o.request("attached", config)[0].result; + o.proc.set(23, { ppid: 21, group: 21, ticks: "300", argv: ["/node", fixture.clientScript, fixture.clientSocket, config.clientNonce] }); + const replies: any[] = [], socket = Object.assign(new EventEmitter(), { end: (value: string) => replies.push(JSON.parse(value)), destroy: vi.fn() }); + o.handlers[1]!(socket); socket.emit("data", Buffer.from(JSON.stringify({ nonce: config.clientNonce, pid: 23 }) + "\n")); + expect(o.children).toHaveLength(1); expect(replies).toHaveLength(0); expect(o.files.has("/workspace/result.txt")).toBe(false); + o.children[0].exitCode = 0; o.children[0].emit("exit", 0, null); + expect(replies).toEqual([{ code: 0 }]); expect(o.files.get("/workspace/result.txt")?.toString()).toBe("settled"); + o.proc.delete(23); const a = o.request("snapshot")[0].result.attached; + expect(a.commandExit.code).toBe(0); expect(BigInt(a.commandExit.observedMonotonicNs)).toBeLessThanOrEqual(BigInt(a.markerWrittenMonotonicNs)); + expect(a.clientExitedAtMs).not.toBeNull(); expect(a.connections).toBe(1); + }); + it("never starts attached work for a same-argv client outside the captured run", async () => { + const o = await observerHarness(); o.request("snapshot"); + const config = { marker: "result.txt", markerText: "settled", delayMs: 500, clientNonce: "test-client-nonce" }; + const fixture = o.request("attached", config)[0].result; + o.proc.set(23, { ppid: 1, group: 23, ticks: "300", argv: ["/node", fixture.clientScript, fixture.clientSocket, config.clientNonce] }); + const socket = Object.assign(new EventEmitter(), { end: vi.fn(), destroy: vi.fn() }); + o.handlers[1]!(socket); socket.emit("data", Buffer.from(JSON.stringify({ nonce: config.clientNonce, pid: 23 }) + "\n")); + expect(o.children).toHaveLength(0); expect(socket.destroy).toHaveBeenCalled(); + expect(o.request("snapshot")[0].result.attached.failure).toBe("client_rejected"); + }); + it("scopes actual runtime symlinks/state churn out while retaining sentinel and sibling coverage", async () => { + const o = await observerHarness(); + o.symbolicLinks.add("/workspace/.paperclip-runtime/paperclip-runner/provider-pack"); + o.files.set("/workspace/.paperclip-runtime/paperclip-runner/bin/paperclip-runnerd", Buffer.alloc(100000)); + o.fs.writeFileSync("/workspace/.paperclip-runtime/paperclip-runner/sessions/state.json", "runtime churn", { flag: "wx" }); + const before = o.request("snapshot")[0].result; + expect(before.complete).toBe(true); expect(before.watcher.workspaceMutationCount).toBe(0); + expect(Object.keys(before.workspace)).toContain(".paperclip-runtime/reusable-sandbox-lease.json"); + expect(Object.keys(before.workspace).some(p => p.startsWith(".paperclip-runtime/paperclip-runner"))).toBe(false); + expect(before.scope.excludedRuntime.ino).toBe("4"); expect(before.scope.observedPrpEnvironmentLeaseId).toBe("workspace-id"); + expect(before.scope.prpEnvironmentLeaseIdVerified).toBe(false); + o.fs.writeFileSync("/workspace/.paperclip-runtime/user-file", "not runtime internal", { flag: "wx" }); + const after = o.request("snapshot")[0].result; + expect(after.watcher.workspaceMutationCount).toBe(1); expect(after.workspace[".paperclip-runtime/user-file"]).toBe(hash("not runtime internal")); + }); + it("rejects runtime root replacement, foreign workspace symlinks and sentinel tampering", async () => { + const replaced = await observerHarness(); replaced.replaceRuntimeRoot(); expect(replaced.request("snapshot")[0].ok).toBe(false); + const linked = await observerHarness(); linked.symbolicLinks.add("/workspace/user-link"); expect(linked.request("snapshot")[0].ok).toBe(false); + const sentinel = await observerHarness(); sentinel.files.set(sentinel.config.sentinel.path, Buffer.from(JSON.stringify({ token: "foreign" }))); + expect(sentinel.request("snapshot")[0].ok).toBe(false); + }); + it("marks PID reuse incomplete rather than mistaking a new process for retired authority", async () => { + const o = await observerHarness(); o.request("snapshot"); const wait = o.request("wait"); + o.proc.set(21, { ppid: 1, group: 99, ticks: "999", argv: ["/unrelated"] }); o.intervals[0]!(); o.timers.find(t => t.ms === 100)!.fn(); + expect(wait[0].result.complete).toBe(false); expect(wait[0].result.processes.root.startTicks).toBe("100"); + }); + it("counts transient workspace create/delete and rejects changed setup-file bytes", async () => { + const o = await observerHarness(); o.request("snapshot"); + o.fs.writeFileSync("/workspace/transient.txt", "not allowed", { flag: "wx" }); o.files.delete("/workspace/transient.txt"); + for (const w of o.watches) if (w.path === "/workspace") w.callback("rename", "transient.txt"); + const snapshot = o.request("snapshot")[0].result; + expect(snapshot.workspace["transient.txt"]).toBeUndefined(); expect(snapshot.watcher.workspaceMutationCount).toBe(2); + o.request("publish", { path: "action.txt", text: "approved" }); o.files.set("/workspace/action.txt", Buffer.from("replacement")); + expect(o.request("snapshot")[0].ok).toBe(false); + }); + it("rejects ambiguous run roots and malformed observed PRP identifiers", async () => { + const o = await observerHarness(); const p = o.proc.get(21)!; + o.proc.set(22, { ...p, group: 22, ticks: "200" }); expect(o.request("snapshot")[0].result.complete).toBe(false); + const other = await observerHarness(); other.proc.get(21)!.argv[4] = "bad value with spaces"; + expect(other.request("snapshot")[0].ok).toBe(false); + }); +}); diff --git a/tests/runner-e2e/remote-native-fixtures.ts b/tests/runner-e2e/remote-native-fixtures.ts new file mode 100644 index 00000000000..924e7a9682a --- /dev/null +++ b/tests/runner-e2e/remote-native-fixtures.ts @@ -0,0 +1,488 @@ +import { createHash, randomBytes } from "node:crypto"; +import { watch, lstatSync } from "node:fs"; +import { posix } from "node:path"; + +export const REMOTE_FIXTURE_DAYTONA_SDK_VERSION = "0.203.0"; +const NODE = "/opt/paperclip-runner/provider-pack/node_modules/node/bin/node"; +const MAX_OUTPUT = 256 * 1024; +const TEARDOWN_RESERVE_MS = 15_000; +const LEASE_READMISSION_BUDGET_MS = 10_000; +const RUNTIME_READY_BUDGET_MS = 12_000; +const INSTALL_BUDGET_MS = 27_000; +// Lease activation can arrive late. Reserve each pre-install operation as well +// as installation and teardown; the caller subtracts this from the same deadline. +export const REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS = LEASE_READMISSION_BUDGET_MS + + RUNTIME_READY_BUDGET_MS + INSTALL_BUDGET_MS + TEARDOWN_RESERVE_MS; +const CLOSE_GRACE_MS = 10_000; +// createRunnerdBackend stages its verified executable, pack symlink, mutable +// sessions, homes and injected context beneath this exact path. Qualification +// covers user workspace files, not these controller/provider runtime internals. +// The sentinel and all other .paperclip-runtime entries remain in scope. +const RUNTIME_RELATIVE = ".paperclip-runtime/paperclip-runner"; +const digest = (value: string) => `sha256:${createHash("sha256").update(value).digest("hex")}`; +const record = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +const fail = (condition: unknown, code: string): void => { if (!condition) throw new Error(`remote_native_fixture:${code}`); }; +const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; +const id = (value: string) => /^[a-zA-Z0-9_-]{1,128}$/u.test(value); +function relative(value: string): string { + fail(value.length <= 240 && /^[a-zA-Z0-9._/-]+$/u.test(value) && !value.startsWith("/") && value.split("/").every(part => part !== "" && part !== "." && part !== ".."), "unsafe_relative_target"); + return value; +} +// Only closed diagnostic enums cross the remote boundary; SDK errors and output +// are never retained. These diagnostics explain failed evidence, not qualification. +const RPC_PHASES = ["runtime-ready", "install", "wait", "close", "arm", "publish", "snapshot", "attached", "read"] as const; +const RPC_CODES = ["node_identity", "sentinel_type", "sentinel", "cwd", "runtime_root_identity", "runtime_binary_identity", "proc_bound", "ambiguous_run_root", "runtime_not_ready", "runtime_identity_changed", "invalid_proc_identity", "invalid_proc_fields", "socket_error", "socket_timeout", "output_bound", "rpc_deadline", "remote_unknown", "transport_failure", "invalid_response", "readiness_deadline"] as const; +type RpcPhase = typeof RPC_PHASES[number]; +type RpcCode = typeof RPC_CODES[number]; +interface RpcDiagnostic { phase: RpcPhase; code: RpcCode } +class RemoteFixtureError extends Error { + constructor(message: string, readonly diagnostic: RpcDiagnostic, options?: ErrorOptions) { super(message, options); } +} +export function remoteNativeFixtureDiagnostics(error: unknown): RpcDiagnostic[] { + const result: RpcDiagnostic[] = []; + for (let current = error, depth = 0; current instanceof Error && depth < 3; current = current.cause, depth++) { + if (current instanceof RemoteFixtureError) result.push({ ...current.diagnostic }); + } + return result; +} +export interface RemoteNativeAuthority { companyId: string; environmentId: string; runId: string; leaseId: string; sandboxId: string; image: string } +export interface RemoteNativeBinding extends RemoteNativeAuthority { remoteCwd: string } +export interface RemoteProcessIdentity { pid: number; ppid: number; startTicks: string; bootId: string } +export interface RemoteNativeSnapshot { + binding: RemoteNativeBinding; + observedAtMs: number; + observedMonotonicNs: string; + receivedAtMs: number; + complete: boolean; + workspace: Record; + targets: Record; + watcher: { complete: boolean; targetMutationCount: number; workspaceMutationCount: number }; + processes: { captured: boolean; root: RemoteProcessIdentity | null; journal: RemoteProcessIdentity[]; live: number[] }; + scope: { + kind: "user_workspace"; + excludedRuntime: { relativePath: string; absolutePath: string; dev: string; ino: string; runnerExecutableSha256: string }; + observedPrpEnvironmentLeaseId: string; + prpEnvironmentLeaseIdVerified: false; + }; + setup: { path: string; sha256: string | null; published: boolean }; + attached: { connections: number; failure: string | null; commandExit: { code: number; observedAtMs: number; observedMonotonicNs: string } | null; markerWrittenAtMs: number | null; markerWrittenMonotonicNs: string | null; clientExitedAtMs: number | null; clientExitedMonotonicNs: string | null } | null; +} +/** Structural subset of pinned SDK0.203.0; caller supplies its authenticated + * client. No create/list/delete or arbitrary remote command API is exposed. */ +export interface RemoteFixtureDaytona { + get(id: string): Promise<{ id: string; labels?: Record; process: { + executeCommand(command: string, cwd?: string, env?: Record, timeout?: number): Promise<{ exitCode: number; result: string }>; + } }>; +} +export interface RemoteFixtureApi { get(path: string, options?: { timeout: number }): Promise } + +/** Linux /proc identity uses boot ID + start ticks, never PID alone. */ +export function parseRemoteProcStat(pid: number, stat: string, bootId: string): RemoteProcessIdentity & { group: number; state: string } { + const close = stat.lastIndexOf(") "); + if (!Number.isSafeInteger(pid) || pid < 2 || close < 0 || !stat.startsWith(`${pid} (`) || !/^[a-f0-9-]{36}$/u.test(bootId)) throw new Error("invalid_proc_identity"); + const fields = stat.slice(close + 2).trim().split(/\s+/u); + if (!/^\d+$/u.test(fields[19] ?? "") || !/^\d+$/u.test(fields[1] ?? "") || !/^\d+$/u.test(fields[2] ?? "")) throw new Error("invalid_proc_fields"); + return { pid, ppid: Number(fields[1]), group: Number(fields[2]), state: fields[0]!, startTicks: fields[19]!, bootId }; +} +export function isRemoteRunRoot(argv: string[], runId: string, process: RemoteProcessIdentity & { group: number }): boolean { + if (!/^[a-zA-Z0-9_-]{1,128}$/u.test(runId) || process.group !== process.pid || !argv[0]?.endsWith("/paperclip-runnerd")) return false; + return [["--run-id", runId], ["--lifecycle-mode", "per_turn"]].every(([flag, value]) => { + const at = argv.indexOf(flag!); return at > 0 && argv.lastIndexOf(flag!) === at && argv[at + 1] === value; + }); +} +/** Shared by the real remote observer and local deterministic tests. */ +export function createRemoteTargetWatch(directory: string, name: string, io = { watch, lstatSync }) { + const before = io.lstatSync(directory, { bigint: true }); + if (!before.isDirectory() || before.isSymbolicLink()) throw new Error("unsafe_watch_parent"); + let complete = true, mutations = 0, events = 0, sealed = false; + const watcher = io.watch(directory, (_kind, filename) => { + if (sealed) return; + events++; + if (filename === null || events > 4096) complete = false; + else if (String(filename) === name) mutations++; + }); + watcher.on("error", () => { complete = false; }); + return { + snapshot() { + let deliveryComplete = true; + try { + const after = io.lstatSync(directory, { bigint: true }); + if (!after.isDirectory() || after.isSymbolicLink() || after.dev !== before.dev || after.ino !== before.ino) complete = false; + if ((after.ctimeNs !== before.ctimeNs || after.mtimeNs !== before.mtimeNs) && events === 0) deliveryComplete = false; + } catch { complete = false; } + return { complete: complete && deliveryComplete, mutationCount: mutations, parent: { dev: String(before.dev), ino: String(before.ino) } }; + }, + close() { this.snapshot(); sealed = true; watcher.close(); }, + }; +} + +// Only this closed Node program executes through the SDK. The observer has no +// general command endpoint. Its control nonce is never given to the provider. +const OBSERVER = String.raw` +const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto'),net=require('node:net'),cp=require('node:child_process'); +const config=JSON.parse(Buffer.from(process.argv[2],'base64').toString()); +const parseStat=PARSE_STAT;const runRoot=RUN_ROOT;const watchTarget=WATCH_TARGET; +const hash=x=>'sha256:'+crypto.createHash('sha256').update(x).digest('hex'); +const cwdStat=fs.lstatSync(config.binding.remoteCwd,{bigint:true}),rootStat=fs.lstatSync(config.root,{bigint:true}),scriptStat=fs.lstatSync(__filename,{bigint:true}),scriptHash=hash(fs.readFileSync(__filename)); +const runtimeRoot=path.join(config.binding.remoteCwd,config.runtimeRelative),runtimeStat=fs.lstatSync(runtimeRoot,{bigint:true});if(!runtimeStat.isDirectory()||runtimeStat.isSymbolicLink()||fs.realpathSync(runtimeRoot)!==runtimeRoot)throw Error('runtime_root_identity');let observedPrpEnvironmentLeaseId=null; +const boot=fs.readFileSync('/proc/sys/kernel/random/boot_id','utf8').trim(); +const targets=new Map();let complete=true,sealed=false,root=null,attached=null,child=null,client=null,childTimer=null; +const journal=new Map(),sockets=new Set(),waiters=new Set(),armWaiters=new Set();let observedRootCount=0,publishedHash=null,finalReceipt=null,retiring=false; +function identity(pid){return parseStat(pid,fs.readFileSync('/proc/'+pid+'/stat','utf8'),boot)} +function table(){const ids=fs.readdirSync('/proc').filter(x=>/^\d+$/.test(x)&&Number(x)>1);if(ids.length>4096)throw Error('process_bound');return ids.flatMap(x=>{try{return [identity(Number(x))]}catch(e){if(e.code==='ENOENT'||e.code==='ESRCH')return [];throw e}})} +function sample(){ + const all=table();const candidates=[]; + for(const p of all){if(p.state==='Z')continue;let argv;try{argv=fs.readFileSync('/proc/'+p.pid+'/cmdline').toString().split('\0').filter(Boolean)}catch(e){if(e.code==='ENOENT'||e.code==='ESRCH')continue;throw e} + if(runRoot(argv,config.binding.runId,p)){const at=argv.indexOf('--environment-lease-id'),stateAt=argv.indexOf('--state-dir');if(at<1||argv.lastIndexOf('--environment-lease-id')!==at||!/^[-a-zA-Z0-9._:]{1,256}$/.test(argv[at+1]??''))throw Error('process_prp_identity_shape');if(argv[0]!==path.join(runtimeRoot,'bin','paperclip-runnerd')||stateAt<1||argv.lastIndexOf('--state-dir')!==stateAt||!argv[stateAt+1]?.startsWith(runtimeRoot+'/sessions/')||!/^([a-f0-9]{64})\/runner$/.test(argv[stateAt+1].slice((runtimeRoot+'/sessions/').length)))throw Error('process_runtime_binding');if(observedPrpEnvironmentLeaseId!==null&&observedPrpEnvironmentLeaseId!==argv[at+1])throw Error('process_prp_identity_changed');observedPrpEnvironmentLeaseId=argv[at+1];candidates.push(p);}} + if(candidates.length>1)complete=false; + if(!root&&candidates.length===1){if(hash(fs.readFileSync('/proc/'+candidates[0].pid+'/exe'))!==config.runnerdSha256)throw Error('runner_binary_identity');root=candidates[0];journal.set(root.pid,root);observedRootCount++;} + if(root&&candidates.some(p=>p.pid!==root.pid||p.startTicks!==root.startTicks))complete=false; + let changed=true;while(changed){changed=false;for(const p of all){const parent=journal.get(p.ppid);if(!journal.has(p.pid)&&((parent&&all.some(q=>q.pid===parent.pid&&q.startTicks===parent.startTicks))||(root&&p.group===root.pid))){if(journal.size>=512)throw Error('journal_bound');journal.set(p.pid,p);changed=true;}}} + if(all.some(p=>journal.has(p.pid)&&journal.get(p.pid).startTicks!==p.startTicks))complete=false; + const live=all.filter(p=>journal.get(p.pid)?.startTicks===p.startTicks&&p.state!=='Z').map(p=>p.pid); + if(client&&!all.some(p=>p.pid===client.pid&&p.startTicks===client.startTicks&&p.state!=='Z')&&attached&&!attached.clientExitedAtMs){attached.clientExitedAtMs=Date.now();attached.clientExitedMonotonicNs=process.hrtime.bigint().toString();} + return {captured:root!==null,root,journal:[...journal.values()],live}; +} +function guard(){const rs=fs.lstatSync(runtimeRoot,{bigint:true});if(!rs.isDirectory()||rs.isSymbolicLink()||rs.dev!==runtimeStat.dev||rs.ino!==runtimeStat.ino||fs.realpathSync(runtimeRoot)!==runtimeRoot)throw Error('runtime_root_replaced');const s=fs.lstatSync(config.root,{bigint:true});if(s.dev!==rootStat.dev||s.ino!==rootStat.ino||!s.isDirectory()||s.isSymbolicLink()||hash(fs.readFileSync(__filename))!==scriptHash||fs.lstatSync(__filename,{bigint:true}).ino!==scriptStat.ino)throw Error('observer_identity_changed'); + const st=fs.lstatSync(config.sentinel.path);if(!st.isFile()||st.isSymbolicLink()||st.size>16384||fs.realpathSync(config.sentinel.path)!==config.sentinel.path)throw Error('sentinel_type');const sentinel=JSON.parse(fs.readFileSync(config.sentinel.path,'utf8'));if(sentinel.version!==1||sentinel.provider!=='daytona'||sentinel.token!==config.sentinel.token||sentinel.companyId!==config.binding.companyId||sentinel.environmentId!==config.binding.environmentId)throw Error('sentinel_changed'); + const c=fs.lstatSync(config.binding.remoteCwd,{bigint:true});if(c.dev!==cwdStat.dev||c.ino!==cwdStat.ino||!c.isDirectory()||c.isSymbolicLink()||fs.realpathSync(config.binding.remoteCwd)!==config.binding.remoteCwd)throw Error('workspace_replaced');} +function readSafe(p){const fd=fs.openSync(p,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW);try{const before=fs.fstatSync(fd,{bigint:true});if(!before.isFile()||before.size>65536n)throw Error('file_bound_or_type');const bytes=fs.readFileSync(fd),after=fs.fstatSync(fd,{bigint:true}),named=fs.lstatSync(p,{bigint:true});if(before.dev!==named.dev||before.ino!==named.ino||named.isSymbolicLink()||before.size!==after.size||before.mtimeNs!==after.mtimeNs||BigInt(bytes.length)!==before.size)throw Error('file_changed');return bytes}finally{fs.closeSync(fd)}} +function file(p){try{const s=fs.lstatSync(p);if(s.isSymbolicLink()||!s.isFile()||s.size>65536)throw Error('file_bound_or_type');return {absent:false,sha256:hash(readSafe(p))}}catch(e){if(e.code==='ENOENT')return {absent:true,sha256:null};throw e}} +function workspace(){const result={};let count=0,bytes=0;function visit(dir,prefix){for(const name of fs.readdirSync(dir).sort()){if(++count>512)throw Error('workspace_entry_bound');const full=path.join(dir,name),rel=prefix+name,s=fs.lstatSync(full);if(rel===config.runtimeRelative)continue;if(rel===config.actionFile){if(!publishedHash||file(full).sha256!==publishedHash)throw Error('setup_file_changed');continue;}if(s.isSymbolicLink())throw Error('workspace_symlink');if(s.isDirectory()){result[rel]='directory';visit(full,rel+'/')}else if(s.isFile()){bytes+=s.size;if(s.size>65536||bytes>4194304)throw Error('workspace_byte_bound');result[rel]=hash(readSafe(full))}else throw Error('workspace_special_file')}}visit(config.binding.remoteCwd,'');return result} +function snapshot(){guard();verifyWorkspaceWatchRoots();const processes=sample(),out={};let watchComplete=complete,total=0;for(const [name,t] of targets){const status=t.watch.snapshot();out[name]={...file(t.path),...status};watchComplete&&=status.complete;total+=status.mutationCount}return {binding:config.binding,observedAtMs:Date.now(),observedMonotonicNs:process.hrtime.bigint().toString(),complete:complete&&watchComplete,workspace:workspace(),targets:out,watcher:{complete:watchComplete,targetMutationCount:total,workspaceMutationCount},processes,scope:{kind:'user_workspace',excludedRuntime:{relativePath:config.runtimeRelative,absolutePath:runtimeRoot,dev:String(runtimeStat.dev),ino:String(runtimeStat.ino),runnerExecutableSha256:config.runnerdSha256},observedPrpEnvironmentLeaseId,prpEnvironmentLeaseIdVerified:false},setup:{path:config.actionFile,sha256:publishedHash,published:publishedHash!==null},attached}} +for(const name of config.targets){const p=path.join(config.binding.remoteCwd,name);if(fs.realpathSync(path.dirname(p))!==path.dirname(p))throw Error('target_parent_symlink');targets.set(name,{path:p,watch:watchTarget(path.dirname(p),path.basename(p),{watch:fs.watch,lstatSync:fs.lstatSync})})} +if(config.crossRoot){const p=path.join(config.root,'cross-root-target');fs.writeFileSync(p,config.crossRoot.initialText,{flag:'wx',mode:0o600});targets.set('@cross-root',{path:p,watch:watchTarget(config.root,'cross-root-target',{watch:fs.watch,lstatSync:fs.lstatSync})})} +let workspaceMutationCount=0;const directoryWatches=[]; +function watchDirectory(directory,prefix=''){if(directoryWatches.length>=512)throw Error('watch_directory_bound');const before=fs.lstatSync(directory,{bigint:true});if(!before.isDirectory()||before.isSymbolicLink())throw Error('watch_directory_identity');const handle=fs.watch(directory,(_kind,name)=>{if(name===null){complete=false;return}const relative=prefix+String(name);if(relative===config.runtimeRelative)return;if(relative===config.actionFile){try{if(!publishedHash||file(path.join(config.binding.remoteCwd,config.actionFile)).sha256!==publishedHash)complete=false}catch{complete=false}return}workspaceMutationCount++;if(workspaceMutationCount>4096)complete=false;try{if(fs.lstatSync(path.join(directory,String(name))).isDirectory())complete=false}catch(e){if(e.code!=='ENOENT')complete=false}});handle.on('error',()=>{complete=false});directoryWatches.push({directory,before,handle});for(const name of fs.readdirSync(directory)){const rel=prefix+name;if(rel===config.runtimeRelative)continue;const full=path.join(directory,name),s=fs.lstatSync(full);if(s.isSymbolicLink())throw Error('workspace_symlink');if(s.isDirectory())watchDirectory(full,rel+'/')}} +watchDirectory(config.binding.remoteCwd); +const workspaceWatch={close(){for(const item of directoryWatches)item.handle.close()}}; +function verifyWorkspaceWatchRoots(){for(const item of directoryWatches){const after=fs.lstatSync(item.directory,{bigint:true});if(after.dev!==item.before.dev||after.ino!==item.before.ino||!after.isDirectory()||after.isSymbolicLink())throw Error('workspace_watch_root_replaced')}} +function publicSnapshot(){const result=snapshot();if(result.attached)result.attached={connections:attached.connections,failure:attached.failure,commandExit:attached.commandExit,markerWrittenAtMs:attached.markerWrittenAtMs,markerWrittenMonotonicNs:attached.markerWrittenMonotonicNs,clientExitedAtMs:attached.clientExitedAtMs,clientExitedMonotonicNs:attached.clientExitedMonotonicNs};return result} +function seal(){if(sealed)return;sealed=true;workspaceWatch.close();for(const t of targets.values())t.watch.close();clearInterval(observer);finalReceipt=publicSnapshot();finalReceipt.files={};for(const [name,t] of targets){if(!file(t.path).absent)finalReceipt.files[name]=readSafe(t.path).toString('base64');}if(Buffer.byteLength(JSON.stringify(finalReceipt))>250000){finalReceipt.complete=false;finalReceipt.files={};}if(child&&child.exitCode===null&&child.signalCode===null)finalReceipt.complete=false;for(const socket of waiters)socket.end(JSON.stringify({ok:true,result:finalReceipt})+'\n');waiters.clear();setTimeout(()=>shutdown(null),250);} +const observer=setInterval(()=>{try{const p=sample();if(p.captured&&p.live.length===0&&!retiring){retiring=true;setTimeout(()=>{try{const end=sample();if(end.live.length===0)seal();else retiring=false}catch{complete=false}},100)}}catch{complete=false}},25); +const server=net.createServer(socket=>{sockets.add(socket);socket.on('close',()=>sockets.delete(socket));socket.on('error',()=>{});let buffer='';socket.on('data',data=>{buffer+=data;if(Buffer.byteLength(buffer)>8192){socket.destroy();complete=false;return}if(!buffer.includes('\n'))return;socket.removeAllListeners('data');try{const r=JSON.parse(buffer);if(r.nonce!==config.nonce)throw Error('control_identity');guard();let result; + if(r.op==='snapshot')result=finalReceipt??publicSnapshot(); + else if(r.op==='wait'){if(finalReceipt)result=finalReceipt;else{if(waiters.size)throw Error('duplicate_receipt_channel');waiters.add(socket);for(const arm of armWaiters)arm.end(JSON.stringify({ok:true,result:{armed:true,sealed:false}})+'\n');armWaiters.clear();socket.on('close',()=>waiters.delete(socket));return}} + else if(r.op==='arm'){if(waiters.size)result={armed:true,sealed};else{armWaiters.add(socket);socket.on('close',()=>armWaiters.delete(socket));return}} + else if(r.op==='publish'){if(sealed||publishedHash||r.path!==config.actionFile||typeof r.text!=='string'||Buffer.byteLength(r.text)>16384)throw Error('publish_bound');const p=path.join(config.binding.remoteCwd,config.actionFile);if(fs.realpathSync(path.dirname(p))!==path.dirname(p))throw Error('publish_parent');publishedHash=hash(r.text);try{fs.writeFileSync(p,r.text,{flag:'wx',mode:0o600})}catch(e){publishedHash=null;throw e}result={path:r.path,sha256:publishedHash,published:true};} + else if(r.op==='read'){const p=r.path==='@cross-root'&&config.crossRoot?path.join(config.root,'cross-root-target'):path.join(config.binding.remoteCwd,r.path);if(r.path!=='@cross-root'&&!config.targets.includes(r.path))throw Error('unregistered_read');const status=file(p);if(status.absent)throw Error('file_missing');result={...status,base64:readSafe(p).toString('base64')};} + else if(r.op==='attached'){if(attached||sealed)throw Error('attached_already_configured');if(!config.targets.includes(r.marker)||!Number.isInteger(r.delayMs)||r.delayMs<100||r.delayMs>8000)throw Error('attached_bounds'); + attached={connections:0,failure:null,commandExit:null,markerWrittenAtMs:null,markerWrittenMonotonicNs:null,clientExitedAtMs:null,clientExitedMonotonicNs:null}; + const clientScript=path.join(config.root,'client.cjs'),clientSocket=path.join(config.root,'attached.sock'); + fs.writeFileSync(clientScript,ATTACHED_CLIENT,{flag:'wx',mode:0o400}); + const srv=net.createServer(s=>{sockets.add(s);s.on('close',()=>sockets.delete(s));s.on('error',()=>{});let b='';s.on('data',d=>{b+=d;if(b.length>1024){s.destroy();attached.failure='client_bound';return}if(!b.includes('\n'))return;s.removeAllListeners('data');try{const q=JSON.parse(b);attached.connections++;if(q.nonce!==r.clientNonce||attached.connections!==1)throw Error('client_identity');client=identity(q.pid);sample();if(journal.get(q.pid)?.startTicks!==client.startTicks)throw Error('client_not_owned_by_run');const argv=fs.readFileSync('/proc/'+q.pid+'/cmdline').toString().split('\0');if(argv[1]!==clientScript||argv[2]!==clientSocket||argv[3]!==r.clientNonce)throw Error('client_argv'); + child=cp.spawn(process.execPath,['-e','setTimeout(()=>process.exit(0),'+r.delayMs+')'],{env:{PATH:'/usr/bin:/bin'},stdio:'ignore'});child.once('error',()=>{attached.failure='child_start';s.destroy()});child.once('exit',(code,signal)=>{attached.commandExit={code:code??-1,observedAtMs:Date.now(),observedMonotonicNs:process.hrtime.bigint().toString()};if(code!==0||signal){attached.failure='child_failed';s.destroy();return}try{fs.writeFileSync(path.join(config.binding.remoteCwd,r.marker),r.markerText,{flag:'wx'});attached.markerWrittenAtMs=Date.now();attached.markerWrittenMonotonicNs=process.hrtime.bigint().toString();s.end(JSON.stringify({code:0})+'\n')}catch{attached.failure='marker_failed';s.destroy()}}); + }catch{attached.failure='client_rejected';s.destroy()}})});srv.listen(clientSocket);attached.server=srv;result={clientScript,clientSocket}; + } + else if(r.op==='finish'){result=snapshot();if(!result.complete||!result.processes.captured||result.processes.live.length)throw Error('retirement_unproven');sealed=true;workspaceWatch.close();for(const t of targets.values())t.watch.close();} + else if(r.op==='close'){shutdown(socket);return;} + else throw Error('unknown_operation'); + // Socket handles never cross the evidence boundary. + if(result?.attached)result={...result,attached:{connections:attached.connections,failure:attached.failure,commandExit:attached.commandExit,markerWrittenAtMs:attached.markerWrittenAtMs,markerWrittenMonotonicNs:attached.markerWrittenMonotonicNs,clientExitedAtMs:attached.clientExitedAtMs,clientExitedMonotonicNs:attached.clientExitedMonotonicNs}}; + socket.end(JSON.stringify({ok:true,result})+'\n'); + }catch{socket.end(JSON.stringify({ok:false,error:'observer_evidence_incomplete'})+'\n')}})}); +let closing=false;async function shutdown(reply){if(closing){reply?.end(JSON.stringify({ok:false,error:'closing'})+'\n');return}closing=true;sealed=true;workspaceWatch.close();for(const t of targets.values())t.watch.close();clearInterval(observer);let settled=true;try{if(child&&child.exitCode===null&&child.signalCode===null){child.kill('SIGTERM');const exited=new Promise(resolve=>child.once('exit',resolve));await Promise.race([exited,new Promise(resolve=>setTimeout(resolve,2000))]);if(child.exitCode===null&&child.signalCode===null){child.kill('SIGKILL');await Promise.race([exited,new Promise(resolve=>setTimeout(resolve,2000))]);}settled=child.exitCode!==null||child.signalCode!==null;}if(reply)reply.end(JSON.stringify({ok:settled,result:{closed:settled}})+'\n');}finally{for(const s of sockets)if(s!==reply)s.destroy();server.close();if(attached?.server)attached.server.close();setTimeout(()=>{reply?.destroy();if(settled){const current=fs.lstatSync(config.root,{bigint:true});if(current.dev===rootStat.dev&¤t.ino===rootStat.ino&&!current.isSymbolicLink())fs.rmSync(config.root,{recursive:true,force:true});else settled=false;}process.exit(settled?0:2)},100)}} +server.listen(path.join(config.root,'control.sock'));setTimeout(()=>{complete=false;shutdown(null)},config.observerTtlMs).unref(); +`; +const ATTACHED_CLIENT = String.raw`const net=require('node:net');const s=net.connect(process.argv[2]);let b='';s.setTimeout(15000,()=>process.exit(3));s.on('error',()=>process.exit(4));s.on('connect',()=>s.write(JSON.stringify({nonce:process.argv[3],pid:process.pid})+'\n'));s.on('data',x=>{b+=x;if(b.length>1024)process.exit(6);if(b.includes('\n')){const r=JSON.parse(b);s.end();process.exit(r.code===0?0:5)}});`; +function observerSource() { + return OBSERVER.replace("PARSE_STAT", () => parseRemoteProcStat.toString()).replace("RUN_ROOT", () => isRemoteRunRoot.toString()) + .replace("WATCH_TARGET", () => createRemoteTargetWatch.toString()).replace("ATTACHED_CLIENT", () => JSON.stringify(ATTACHED_CLIENT)); +} +const RPC = String.raw`const fs=require('node:fs'),net=require('node:net'),cp=require('node:child_process'),crypto=require('node:crypto');const r=JSON.parse(Buffer.from(process.argv[1],'base64').toString());const hash=x=>'sha256:'+crypto.createHash('sha256').update(x).digest('hex'); +const startedAt=Date.now();if(!Number.isInteger(r.timeoutMs)||r.timeoutMs<1000||r.timeoutMs>300000)throw Error('rpc_deadline');setTimeout(()=>{failure('rpc_deadline');process.exit(2)},r.timeoutMs).unref(); +const parseStat=PARSE_STAT,runRoot=RUN_ROOT; +const codes=RPC_CODES;let failed=false;const phase=RPC_PHASES.includes(r.op)?r.op:'install'; +function failure(code){if(failed)return;failed=true;process.exitCode=2;process.stdout.write(JSON.stringify({ok:false,diagnostic:{phase,code:codes.includes(code)?code:'remote_unknown'}})+'\n')} +function admitted(c){const st=fs.lstatSync(c.sentinel.path);if(!st.isFile()||st.isSymbolicLink()||st.size>16384||fs.realpathSync(c.sentinel.path)!==c.sentinel.path)throw Error('sentinel_type');const s=JSON.parse(fs.readFileSync(c.sentinel.path,'utf8'));if(s.version!==1||s.provider!=='daytona'||s.token!==c.sentinel.token||s.companyId!==c.binding.companyId||s.environmentId!==c.binding.environmentId)throw Error('sentinel');if(fs.realpathSync(c.binding.remoteCwd)!==c.binding.remoteCwd)throw Error('cwd')} +function runtime(c){const root=c.binding.remoteCwd+'/'+c.runtimeRelative,boot=fs.readFileSync('/proc/sys/kernel/random/boot_id','utf8').trim();let s;try{s=fs.lstatSync(root,{bigint:true})}catch(e){if(e.code==='ENOENT')return {ready:false};throw e}if(!s.isDirectory()||s.isSymbolicLink()||fs.realpathSync(root)!==root)throw Error('runtime_root_identity');const matches=[];const entries=fs.readdirSync('/proc');if(entries.length>8192)throw Error('proc_bound');for(const name of entries){if(!/^\d+$/.test(name)||Number(name)<2)continue;try{const p=parseStat(Number(name),fs.readFileSync('/proc/'+name+'/stat','utf8'),boot),argv=fs.readFileSync('/proc/'+name+'/cmdline').toString().split('\0').filter(Boolean);if(runRoot(argv,c.binding.runId,p)){if(argv[0]!==root+'/bin/paperclip-runnerd'||hash(fs.readFileSync('/proc/'+name+'/exe'))!==c.runnerdSha256)throw Error('runtime_binary_identity');if(p.state!=='Z')matches.push({pid:p.pid,ppid:p.ppid,startTicks:p.startTicks,bootId:p.bootId})}}catch(e){if(e.code!=='ENOENT'&&e.code!=='ESRCH')throw e}}if(matches.length>1)throw Error('ambiguous_run_root');return matches.length===1?{ready:true,binding:c.binding,root:matches[0],runtime:{dev:String(s.dev),ino:String(s.ino),runnerExecutableSha256:c.runnerdSha256}}:{ready:false}} +(async()=>{let controlRequest=r;if(hash(fs.readFileSync(process.execPath))!==r.nodeSha256)throw Error('node_identity');if(r.op==='runtime-ready'||r.op==='install'){const c=r.config;admitted(c);const current=runtime(c);if(r.op==='runtime-ready'){process.stdout.write(JSON.stringify({ok:true,result:current})+'\n');return}if(!current.ready)throw Error('runtime_not_ready');if(JSON.stringify(current)!==JSON.stringify(r.expectedRuntime))throw Error('runtime_identity_changed');fs.mkdirSync(c.root,{mode:0o700});fs.writeFileSync(c.root+'/observer.cjs',r.source,{flag:'wx',mode:0o400});c.observerTtlMs=Math.max(1,c.observerTtlMs-(Date.now()-startedAt));const child=cp.spawn(process.execPath,[c.root+'/observer.cjs',Buffer.from(JSON.stringify(c)).toString('base64')],{detached:true,stdio:'ignore',env:{PATH:'/usr/bin:/bin'}});child.unref();r.root=c.root;controlRequest={op:'snapshot',nonce:c.nonce};} +for(let i=0;!fs.existsSync(r.root+'/control.sock')&&i<200;i++)await new Promise(resolve=>setTimeout(resolve,10));const socket=net.connect(r.root+'/control.sock');let output='';socket.setTimeout(Math.max(1,r.timeoutMs-(Date.now()-startedAt)));socket.on('timeout',()=>{socket.destroy();failure('socket_timeout')});socket.on('error',()=>{failure('socket_error')});socket.on('connect',()=>socket.write(JSON.stringify(controlRequest)+'\n'));socket.on('data',b=>{output+=b;if(Buffer.byteLength(output)>262144){socket.destroy();failure('output_bound')}});socket.on('end',()=>{if(!process.exitCode)process.stdout.write(output)}); +})().catch(e=>{failure(typeof e?.message==='string'?e.message:'remote_unknown')});`; + +function rpcSource() { + return RPC.replace("RPC_CODES", () => JSON.stringify(RPC_CODES)).replace("RPC_PHASES", () => JSON.stringify(RPC_PHASES)).replace("PARSE_STAT", () => parseRemoteProcStat.toString()).replace("RUN_ROOT", () => isRemoteRunRoot.toString()); +} + +export interface RemoteNativeFixture { + readonly binding: RemoteNativeBinding; + readonly remoteCwd: string; + readonly actionFile: string; + readonly outsideTarget: string | null; + /** Watchers and exact run-root identity are already armed at return from bind. */ + readonly baseline: RemoteNativeSnapshot; + snapshot(label: string): Promise; + readFile(path: string): Promise; + publishAction(path: string, text: string): Promise; + setupAttachedCommand(input: { marker: string; markerText: string; delayMs: number }): Promise<{ command: string; commandSha256: string }>; + /** Consumes the host-held terminal receipt; never queries a deleted lease. */ + finish(): Promise; + /** Only fixture-owned observer cleanup. Never deletes a sandbox or lease. */ + close(): Promise; +} +export interface RemoteNativeFixtureOptions { + api: RemoteFixtureApi; + daytona: RemoteFixtureDaytona; + sdkVersion: typeof REMOTE_FIXTURE_DAYTONA_SDK_VERSION; + authority: RemoteNativeAuthority; + nodeSha256: string; + runnerdSha256: string; + targets: string[]; + actionFile: string; + /** Cell deadline; receipt collection stops 15s earlier for public teardown. */ + deadlineAt: number; + crossRoot?: { initialText: string }; +} +const sha = (value: unknown): value is string => typeof value === "string" && /^sha256:[a-f0-9]{64}$/u.test(value); +function readSnapshot(value: unknown, binding: RemoteNativeBinding, names: string[], actionFile: string, runnerdSha256: string): RemoteNativeSnapshot { + const row = record(value), watcher = record(row.watcher), processes = record(row.processes), setup = record(row.setup); + fail(JSON.stringify(row.binding) === JSON.stringify(binding), "receipt_binding"); + fail(Number.isSafeInteger(row.observedAtMs) && (row.observedAtMs as number) > 0 && typeof row.observedMonotonicNs === "string" && /^\d+$/u.test(row.observedMonotonicNs) && typeof row.complete === "boolean", "receipt_shape"); + fail(Object.keys(record(row.workspace)).length <= 512 && Object.entries(record(row.workspace)).every(([path, hash]) => { + try { relative(path); return hash === "directory" || sha(hash); } catch { return false; } + }), "workspace_shape"); + fail(Object.keys(record(row.targets)).sort().join("\0") === [...names].sort().join("\0"), "target_set"); + for (const target of Object.values(record(row.targets))) { + const t = record(target), parent = record(t.parent); + fail(typeof t.absent === "boolean" && (t.absent ? t.sha256 === null : sha(t.sha256)) && typeof t.complete === "boolean" + && Number.isSafeInteger(t.mutationCount) && (t.mutationCount as number) >= 0 + && /^\d+$/u.test(String(parent.dev)) && /^\d+$/u.test(String(parent.ino)), "target_shape"); + } + fail(typeof watcher.complete === "boolean" && [watcher.targetMutationCount, watcher.workspaceMutationCount].every(n => Number.isSafeInteger(n) && (n as number) >= 0), "watcher_shape"); + const validProcess = (p: unknown) => { + const i = record(p); return Number.isSafeInteger(i.pid) && (i.pid as number) >= 2 && Number.isSafeInteger(i.ppid) && (i.ppid as number) >= 0 + && typeof i.startTicks === "string" && /^\d+$/u.test(i.startTicks) && typeof i.bootId === "string" && /^[a-f0-9-]{36}$/u.test(i.bootId); + }; + fail(typeof processes.captured === "boolean" && (processes.captured ? validProcess(processes.root) : processes.root === null) + && Array.isArray(processes.journal) && processes.journal.length <= 512 && processes.journal.every(validProcess) + && Array.isArray(processes.live) && processes.live.length <= 512 + && processes.live.every(pid => (processes.journal as unknown[]).some((p: unknown) => record(p).pid === pid)), "process_shape"); + const scope = record(row.scope), excluded = record(scope.excludedRuntime); + fail(scope.kind === "user_workspace" && excluded.relativePath === RUNTIME_RELATIVE && excluded.absolutePath === `${binding.remoteCwd}/${RUNTIME_RELATIVE}` + && /^\d+$/u.test(String(excluded.dev)) && /^\d+$/u.test(String(excluded.ino)) && excluded.runnerExecutableSha256 === runnerdSha256 + && typeof scope.observedPrpEnvironmentLeaseId === "string" && /^[-a-zA-Z0-9._:]{1,256}$/u.test(scope.observedPrpEnvironmentLeaseId) + && scope.prpEnvironmentLeaseIdVerified === false, "evidence_scope"); + fail(setup.path === actionFile && typeof setup.published === "boolean" && (setup.published ? sha(setup.sha256) : setup.sha256 === null), "setup_shape"); + if (row.attached !== null) { + const a = record(row.attached), exit = record(a.commandExit); + fail(Number.isSafeInteger(a.connections) && (a.connections as number) >= 0 && (a.failure === null || ["client_bound", "child_start", "child_failed", "marker_failed", "client_rejected"].includes(String(a.failure))) + && (a.commandExit === null || (Number.isSafeInteger(exit.code) && Number.isSafeInteger(exit.observedAtMs) && typeof exit.observedMonotonicNs === "string" && /^\d+$/u.test(exit.observedMonotonicNs))) + && [a.markerWrittenAtMs, a.clientExitedAtMs].every(n => n === null || Number.isSafeInteger(n)) + && [a.markerWrittenMonotonicNs, a.clientExitedMonotonicNs].every(n => n === null || typeof n === "string" && /^\d+$/u.test(n)), "attached_shape"); + } + const { files: _files, ...publicReceipt } = row; + return { ...publicReceipt, receivedAtMs: Date.now() } as unknown as RemoteNativeSnapshot; +} + +/** Must be called while the initial native input-file bootstrap holds the run. + * No effect-producing task is published until baseline proves watcher + PID + * admission. The exact controller runtime subtree is excluded only after its + * directory inode/realpath, pinned executable and run/state-dir binding agree. + * The PRP environment ID can be a durable workspace identity, not the sandbox + * database lease ID; it is retained as observed, explicitly unverified metadata. + * Same-UID observer opacity is not an OS adversarial sandbox. */ +export async function bindRemoteNativeFixture(options: RemoteNativeFixtureOptions): Promise { + const { authority, api, daytona } = options; + // This reserve is never borrowed by normal RPCs or finish(). Only observer + // close has an independent 10s cleanup grace; public lease teardown is caller-owned. + const receiptDeadlineAt = options.deadlineAt - TEARDOWN_RESERVE_MS; + fail(options.sdkVersion === REMOTE_FIXTURE_DAYTONA_SDK_VERSION, "sdk_pin"); + fail(Object.entries(authority).every(([key, value]) => key === "image" ? typeof value === "string" && /^[^\s]+@sha256:[a-f0-9]{64}$/u.test(value) : typeof value === "string" && id(value)), "authority_shape"); + fail(sha(options.nodeSha256) && sha(options.runnerdSha256), "binary_pins"); + fail(Number.isFinite(options.deadlineAt) && options.deadlineAt - Date.now() >= REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS, "insufficient_setup_budget"); + fail(options.targets.length <= 8 && new Set(options.targets).size === options.targets.length, "target_bound"); + const targets = options.targets.map(relative), actionFile = relative(options.actionFile); + fail(!targets.includes(actionFile), "setup_target_overlap"); + fail([...targets, actionFile].every(path => path !== RUNTIME_RELATIVE && !path.startsWith(`${RUNTIME_RELATIVE}/`)), "runtime_target_forbidden"); + fail(!options.crossRoot || Buffer.byteLength(options.crossRoot.initialText) <= 4096, "cross_root_bound"); + const root = `/tmp/pc-native-${randomBytes(18).toString("hex")}`, nonce = randomBytes(32).toString("hex"); + let binding: RemoteNativeBinding | undefined, sentinel: { path: string; token: string } | undefined, identityKey: string | undefined; + async function admittedSandbox() { + const list = await api.get(`/api/environments/${authority.environmentId}/leases`); + const row = record(await api.get(`/api/environment-leases/${authority.leaseId}`)); + fail(Array.isArray(list) && list.filter(item => record(item).id === authority.leaseId).length === 1, "lease_list"); + function admitted(row: Record) { + const metadata = record(row.metadata), sent = record(metadata.workspaceSentinel), cwd = metadata.remoteCwd; + fail(row.id === authority.leaseId && row.companyId === authority.companyId && row.environmentId === authority.environmentId + && row.heartbeatRunId === authority.runId && row.provider === "daytona" && row.providerLeaseId === authority.sandboxId + && row.status === "active" && row.releasedAt == null, "lease_scope"); + fail(metadata.sandboxId === authority.sandboxId && metadata.image === authority.image && metadata.reuseLease === false, "lease_metadata"); + fail(typeof cwd === "string" && cwd.startsWith("/") && cwd.length < 512 && posix.normalize(cwd) === cwd && cwd !== "/" && !cwd.includes("\0"), "remote_cwd"); + fail(sent.path === `${cwd}/.paperclip-runtime/reusable-sandbox-lease.json` && sent.result === "written" && typeof sent.token === "string" + && sent.token.length >= 16 && sent.token.length <= 256 && sent.runId === authority.runId && sent.providerLeaseId === authority.sandboxId, "sentinel_binding"); + return { binding: { ...authority, remoteCwd: cwd as string }, sentinel: { path: sent.path as string, token: sent.token as string } }; + } + const current = admitted(row), listed = admitted(record((list as unknown[]).find(item => record(item).id === authority.leaseId))); + const key = JSON.stringify(current); + fail(JSON.stringify(listed) === key && (!identityKey || identityKey === key), "lease_identity_drift"); + const sandbox = await daytona.get(authority.sandboxId); + fail(sandbox.id === authority.sandboxId && sandbox.labels?.["paperclip-provider"] === "daytona" + && sandbox.labels?.["paperclip-company-id"] === authority.companyId && sandbox.labels?.["paperclip-environment-id"] === authority.environmentId + && sandbox.labels?.["paperclip-run-id"] === authority.runId && sandbox.labels?.["paperclip-reuse-lease"] === "false", "sandbox_labels"); + binding = current.binding; sentinel = current.sentinel; identityKey = key; + return sandbox; + } + async function bounded(operation: () => Promise, budgetMs: number): Promise { + fail(budgetMs >= 1, "receipt_deadline"); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation(), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("remote_native_fixture:remote_command_failed_or_deadline")), budgetMs); + timer.unref(); + }), + ]); + } finally { if (timer) clearTimeout(timer); } + } + async function rpc(request: Record, admitted?: Awaited>, deadlineAt = receiptDeadlineAt) { + const available = request.op === "close" ? CLOSE_GRACE_MS : deadlineAt - Date.now(); + const cap = request.op === "install" ? INSTALL_BUDGET_MS : request.op === "wait" ? 300_000 : RUNTIME_READY_BUDGET_MS; + const budgetMs = Math.floor(Math.min(available, cap) / 1000) * 1000; + fail(budgetMs >= 1000, "receipt_deadline"); + if (request.op === "install") fail(budgetMs >= 25_000, "insufficient_setup_budget"); + const operationDeadline = Date.now() + budgetMs; + return bounded(async () => { + const sandbox = admitted ?? await admittedSandbox(); + const commandCap = request.op === "install" ? 25_000 : request.op === "wait" ? 300_000 : 10_000; + const timeoutMs = Math.floor(Math.min(operationDeadline - Date.now(), commandCap) / 1000) * 1000; + fail(timeoutMs >= 1000, "receipt_deadline"); + const config = request.op === "install" + ? { ...record(request.config), observerTtlMs: Math.floor((receiptDeadlineAt - Date.now()) / 1000) * 1000 } + : undefined; + const payload = Buffer.from(JSON.stringify({ ...request, ...(config ? { config } : {}), root, nonce, nodeSha256: options.nodeSha256, timeoutMs })).toString("base64"); + const command = `/usr/bin/env -i PATH=/usr/bin:/bin ${quote(NODE)} -e ${quote(rpcSource())} ${quote(payload)}`; + let response: { exitCode: number; result: string }; + try { response = await sandbox.process.executeCommand(command, binding!.remoteCwd, {}, timeoutMs / 1000); } + catch { throw new RemoteFixtureError("remote_native_fixture:remote_command_failed_or_deadline", { phase: request.op as RpcPhase, code: "transport_failure" }); } + if (typeof response.result !== "string" || Buffer.byteLength(response.result) > MAX_OUTPUT) throw new RemoteFixtureError("remote_native_fixture:command_failed_or_output_bound", { phase: request.op as RpcPhase, code: "output_bound" }); + if (response.exitCode !== 0) { + let diagnostic: RpcDiagnostic = { phase: request.op as RpcPhase, code: "invalid_response" }; + try { + const value = record(JSON.parse(response.result)), d = record(value.diagnostic); + if (value.ok === false && Object.keys(value).sort().join() === "diagnostic,ok" && Object.keys(d).sort().join() === "code,phase" + && d.phase === request.op && RPC_CODES.includes(d.code as RpcCode)) diagnostic = { phase: d.phase as RpcPhase, code: d.code as RpcCode }; + } catch { /* Never retain remote text. */ } + throw new RemoteFixtureError(`remote_native_fixture:command_failed_or_output_bound:${diagnostic.phase}:${diagnostic.code}`, diagnostic); + } + let parsed: Record; + try { parsed = record(JSON.parse(response.result)); } catch { throw new Error("remote_native_fixture:invalid_observer_json"); } + fail(parsed.ok === true, "observer_incomplete"); + return parsed.result; + }, budgetMs); + } + const sandbox = await bounded(admittedSandbox, Math.min(LEASE_READMISSION_BUDGET_MS, receiptDeadlineAt - Date.now())); + const names = [...targets, ...(options.crossRoot ? ["@cross-root"] : [])]; + const config = { root, nonce, binding, sentinel, targets, actionFile, crossRoot: options.crossRoot, runtimeRelative: RUNTIME_RELATIVE, runnerdSha256: options.runnerdSha256 }; + // Lease activation precedes runner artifact staging. Observe the exact pinned + // run root before spending the single installation budget. No observer or + // action exists during this polling phase, and failures are never retried. + const readyDeadline = receiptDeadlineAt - INSTALL_BUDGET_MS; + let expectedRuntime: Record; + while (true) { + if (readyDeadline - Date.now() < 1000) throw new RemoteFixtureError("remote_native_fixture:readiness_deadline", { phase: "runtime-ready", code: "readiness_deadline" }); + const value = record(await rpc({ op: "runtime-ready", config }, undefined, readyDeadline)); + if (value.ready === true) { + const process = record(value.root), runtime = record(value.runtime); + fail(Object.keys(value).sort().join() === "binding,ready,root,runtime" && JSON.stringify(value.binding) === JSON.stringify(binding) + && Object.keys(process).sort().join() === "bootId,pid,ppid,startTicks" && Number.isSafeInteger(process.pid) && Number(process.pid) > 1 + && Number.isSafeInteger(process.ppid) && Number(process.ppid) >= 0 && typeof process.startTicks === "string" && /^\d+$/u.test(process.startTicks) + && typeof process.bootId === "string" && /^[a-f0-9-]{36}$/u.test(process.bootId) + && Object.keys(runtime).sort().join() === "dev,ino,runnerExecutableSha256" && typeof runtime.dev === "string" && /^\d+$/u.test(runtime.dev) + && typeof runtime.ino === "string" && /^\d+$/u.test(runtime.ino) && runtime.runnerExecutableSha256 === options.runnerdSha256, "runtime_ready_identity"); + expectedRuntime = value; break; + } + fail(value.ready === false && Object.keys(value).length === 1, "runtime_ready_shape"); + await new Promise(resolve => setTimeout(resolve, Math.min(200, Math.max(0, readyDeadline - Date.now())))); + } + let baseline: RemoteNativeSnapshot; + try { + baseline = readSnapshot(await rpc({ op: "install", config, expectedRuntime, source: observerSource() }), binding!, names, actionFile, options.runnerdSha256); + fail(baseline.complete && baseline.processes.captured && baseline.processes.live.length > 0 && baseline.watcher.complete && !baseline.setup.published, "bootstrap_not_held"); + fail((["pid", "ppid", "startTicks", "bootId"] as const).every(key => baseline.processes.root?.[key] === record(expectedRuntime.root)[key]) + && baseline.scope.excludedRuntime.dev === record(expectedRuntime.runtime).dev && baseline.scope.excludedRuntime.ino === record(expectedRuntime.runtime).ino, "runtime_identity_changed"); + } catch (error) { + // Only the nonce/inode-bound observer can acknowledge this cleanup. If + // launch failed before its socket became available, TTL remains a bound, + // not a claimed successful cleanup receipt. + try { await rpc({ op: "close" }, sandbox); } + catch (cleanupError) { throw new RemoteFixtureError("remote_native_fixture:startup_failed_cleanup_unproven", remoteNativeFixtureDiagnostics(cleanupError)[0] ?? { phase: "close", code: "remote_unknown" }, { cause: error }); } + throw error; + } + // Start receiving while the lease is still authorized, before publishAction. + // Rejection is retained (no unhandled rejection); finish reports it unchanged. + let stopReceipt!: () => void; + const cancelledReceipt = new Promise((_resolve, reject) => { stopReceipt = () => reject(new Error("remote_native_fixture:closed_before_receipt")); }); + const terminal = Promise.race([rpc({ op: "wait" }, sandbox), cancelledReceipt]) + .then(value => ({ value, receivedAtMs: Date.now() }), error => ({ error })); + try { + const armed = record(await rpc({ op: "arm" }, sandbox)); + fail(armed.armed === true && armed.sealed === false, "receipt_channel_not_armed"); + } catch (error) { + try { await rpc({ op: "close" }, sandbox); } + catch { throw new Error("remote_native_fixture:receipt_channel_failed_cleanup_unproven", { cause: error }); } + stopReceipt(); + throw error; + } + let closed = false, published = false, finished: RemoteNativeSnapshot | undefined; + const retainedFiles = new Map(); + return { + binding: binding!, remoteCwd: binding!.remoteCwd, actionFile, outsideTarget: options.crossRoot ? `${root}/cross-root-target` : null, baseline, + async snapshot(label) { + fail(typeof label === "string" && /^[a-zA-Z0-9_-]{1,80}$/u.test(label), "snapshot_label"); + fail(!closed && !finished, "fixture_closed"); + return readSnapshot(await rpc({ op: "snapshot" }), binding!, names, actionFile, options.runnerdSha256); + }, + async readFile(path) { + fail(!closed && names.includes(path), "unregistered_read"); + if (finished) { const bytes = retainedFiles.get(path); fail(bytes, "terminal_file_missing"); return Buffer.from(bytes!); } + const data = record(await rpc({ op: "read", path })); + fail(typeof data.base64 === "string" && data.base64.length <= 87384 && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(data.base64), "read_bound"); + const bytes = Buffer.from(data.base64 as string, "base64"); + fail(bytes.length <= 65536 && `sha256:${createHash("sha256").update(bytes).digest("hex")}` === data.sha256, "read_digest"); + return bytes; + }, + async publishAction(path, text) { + fail(!closed && !published && path === actionFile && typeof text === "string" && Buffer.byteLength(text) <= 16384, "publish_bound"); + // Set before RPC: uncertain delivery must never cause a second publish. + published = true; + const ack = record(await rpc({ op: "publish", path, text })); + fail(ack.published === true && ack.path === path && ack.sha256 === digest(text), "publish_ack"); + }, + async setupAttachedCommand(input) { + fail(!closed && !published && targets.includes(input.marker) && Number.isInteger(input.delayMs) && input.delayMs >= 100 && input.delayMs <= 8000 + && typeof input.markerText === "string" && Buffer.byteLength(input.markerText) <= 4096, "attached_bound"); + const clientNonce = randomBytes(24).toString("hex"); + const result = record(await rpc({ op: "attached", ...input, clientNonce })); + fail(result.clientScript === `${root}/client.cjs` && result.clientSocket === `${root}/attached.sock`, "attached_identity"); + const command = `${quote(NODE)} ${quote(result.clientScript as string)} ${quote(result.clientSocket as string)} ${quote(clientNonce)}`; + return { command, commandSha256: digest(command) }; + }, + async finish() { + if (finished) return finished; + const receipt = await terminal; + if ("error" in receipt) throw receipt.error; + fail(receipt.receivedAtMs <= receiptDeadlineAt, "receipt_deadline"); + const result = readSnapshot(receipt.value, binding!, names, actionFile, options.runnerdSha256); + fail(published && result.setup.published && result.complete && result.watcher.complete && result.processes.captured && result.processes.live.length === 0, "terminal_evidence_incomplete"); + const files = record(record(receipt.value).files); + for (const name of names) { + const target = result.targets[name]!; + if (target.absent) { fail(files[name] === undefined, "terminal_file_presence"); continue; } + fail(typeof files[name] === "string" && (files[name] as string).length <= 87384, "terminal_file_bound"); + const bytes = Buffer.from(files[name] as string, "base64"); + fail(bytes.length <= 65536 && `sha256:${createHash("sha256").update(bytes).digest("hex")}` === target.sha256, "terminal_file_digest"); + retainedFiles.set(name, bytes); + } + result.receivedAtMs = receipt.receivedAtMs; + finished = result; return result; + }, + async close() { + if (closed) return; + closed = true; + // A finalized receipt survives ordinary public lease deletion. If the + // lease still exists, clean our opaque observer; never discover by name. + if (!finished) { stopReceipt(); await rpc({ op: "close" }); } + }, + }; +} diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index f94562fbb4c..10ed14b10a1 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -5,6 +5,10 @@ import { assertNativeBlockerReply } from "./native-blocker-visible.js"; import { warmManagedFileEvidence } from "./warm-managed-files.js"; import { gitFinalizationEvidence, gitStreamingEvidence, setupGitStreamingWorkspace } from "./daytona-git-streaming.js"; import { runsCompletionUpdateProbe, completionQualityControls, completionQualityStatus, judgeCompletionQuality, reserveCompletionQuality, type CompletionQualityRecord } from "./completion-quality.js"; +import { runNativeActiveStopFlow } from "./native-active-stop-flow.js"; +import { runCursorNativeFlow } from "./cursor-native-flow.js"; +import { createRemoteNativeBootstrap, createRemoteFixtureClient } from "./remote-native-bootstrap.js"; +import { runCleanupWithObservers, verifyCleanupAssertions, type CleanupAssertion } from "./cleanup-verification.js"; import { completionDelivery, type CompletionObservation } from "./completion-updates.js"; import { runInstructionPersistenceFlow } from "./instruction-persistence.js"; import { gradeApiResponsePaging, readResponseProof, responseEvidenceDescription } from "./api-response-reading.js"; @@ -565,7 +569,7 @@ for (const execution of executions) { const credentials = credentialValues(); const secrets = normalizedSecrets(Object.values(credentials)); const api = new RunnerApi(request); - const companyRunFlow = execution.suite.id === "task-titles" || ["blocker_guidance", "continuation_accounting", "continuation", "context_integrity", "agent_chat", "everyday_workflow", "first_task", "instruction_persistence"].includes(execution.task.flow); + const companyRunFlow = execution.suite.id === "task-titles" || ["blocker_guidance", "continuation_accounting", "continuation", "context_integrity", "agent_chat", "everyday_workflow", "first_task", "instruction_persistence", "cursor_native", "native_active_stop"].includes(execution.task.flow); const consoleDiagnostics: Array> = []; const networkDiagnostics: Array> = []; const pageLifecycleDiagnostics: Array> = []; @@ -611,6 +615,16 @@ for (const execution of executions) { let primaryError: unknown; let failureClassOverride: FailureClass | undefined; let cleanup: RunnerE2EResult["cleanup"] = "not_started"; + const cleanupAssertions: CleanupAssertion[] = []; + const beforeEnvironmentTeardownAssertions: CleanupAssertion[] = []; + const registerCleanupAssertion = (assertion: CleanupAssertion) => { + if (cleanupAssertions.length >= 8) throw new Error("Cleanup assertion bound exceeded"); + cleanupAssertions.push(assertion); + }; + const registerBeforeEnvironmentTeardownAssertion = (assertion: CleanupAssertion) => { + if (beforeEnvironmentTeardownAssertions.length >= 8) throw new Error("Remote cleanup assertion bound exceeded"); + beforeEnvironmentTeardownAssertions.push(assertion); + }; const capturePrivateScreenshot = async (id: string, file: string) => { const screenshotPath = path.join(privateDir, file); @@ -870,6 +884,17 @@ for (const execution of executions) { ]); nativeInitial = { issueIds: issues.map(value => value.id), agentIds: agents.map(value => value.id), workspaceDigest }; } + const remoteBootstrap = execution.environment.id === "daytona" + && ["cursor_native", "native_active_stop"].includes(execution.task.flow) + ? createRemoteNativeBootstrap({ + api, daytona: await createRemoteFixtureClient(credentials.DAYTONA_API_KEY ?? ""), + companyId: fixtures.company.id, environmentId: fixtures.environment.id, agentId: fixtures.agent.id, + image: process.env.PAPERCLIP_E2E_DAYTONA_IMAGE ?? "", + nodeSha256: process.env.PAPERCLIP_E2E_DAYTONA_NODE_SHA256 ?? "", + runnerdSha256: process.env.PAPERCLIP_E2E_DAYTONA_RUNNERD_SHA256 ?? "", + deadlineAt: startedAtMs + deadlineMs, + evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets), + }) : undefined; if (execution.suite.id === "stock-harness") { const hire = await captureStockHarness({ api, companyId: fixtures.company.id, @@ -950,6 +975,25 @@ for (const execution of executions) { evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets), }); issue = accounting.issue as IssueRecord; selectedRuns = accounting.runs as RunRecord[]; + } else if (execution.task.flow === "native_active_stop") { + const story = await runNativeActiveStopFlow({ + page, api, fixtures, execution, nonce, workspacePath, deadlineAt: startedAtMs + deadlineMs, + observe: (currentIssue, currentRuns) => { issue = currentIssue as IssueRecord; selectedRuns = currentRuns as RunRecord[]; }, + capture: captureScreenshot, evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets), + remoteBootstrap, registerCleanupAssertion, registerBeforeEnvironmentTeardownAssertion, + }); + issue = story.issue as IssueRecord; selectedRuns = story.runs as RunRecord[]; + matcherResults = story.checks.map(check => ({ matcher: { kind: "json_path" as const, path: `nativeActiveStop.${check.id}`, expected: true }, passed: check.passed, detail: check.detail })); + } else if (execution.task.flow === "cursor_native") { + const story = await runCursorNativeFlow({ + page, api, fixtures, execution, nonce, workspacePath, deadlineAt: startedAtMs + deadlineMs, + observe: (currentIssue, currentRuns) => { issue = currentIssue as IssueRecord; selectedRuns = currentRuns as RunRecord[]; }, + capture: captureScreenshot, + evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets), + remoteBootstrap, registerCleanupAssertion, registerBeforeEnvironmentTeardownAssertion, + }); + issue = story.issue as IssueRecord; selectedRuns = story.runs as RunRecord[]; + matcherResults = story.checks.map(check => ({ matcher: { kind: "json_path" as const, path: `cursorNative.${check.id}`, expected: true }, passed: check.passed, detail: check.detail })); } else if (execution.task.flow === "instruction_persistence") { const story = await runInstructionPersistenceFlow({ page, api, fixtures, execution, nonce, secrets, deadlineAt: startedAtMs + deadlineMs, @@ -1572,7 +1616,7 @@ for (const execution of executions) { (execution.task.turnTimeoutMs ?? 10 * 60_000), ); const waitingState = await pollUntil({ - label: `warm Daytona turn ${completedTurn} review state for issue ${issue.id}`, + label: `warm ${execution.environment.id} turn ${completedTurn} review state for issue ${issue.id}`, deadlineAt: turnDeadlineAt, load: loadTaskState, accept: ({ currentIssue, taskRuns, interactions }) => { @@ -1631,78 +1675,81 @@ for (const execution of executions) { const completedRunIds = new Set( chronologicalRuns.map((candidate) => candidate.id), ); - const retainedTurnLeases = await pollUntil({ - label: `retained Daytona leases after warm turn ${completedTurn}`, - deadlineAt: Math.min(turnDeadlineAt, Date.now() + 30_000), - intervalMs: 500, - load: () => - api.get( - `/api/environments/${fixtures!.environment.id}/leases`, - ), - accept: (leases) => { - const runOrder = new Map( - chronologicalRuns.map((candidate, index) => [ - candidate.id, - index, - ]), - ); - const completed = leases - .filter( - (lease) => - lease.heartbeatRunId && - completedRunIds.has(lease.heartbeatRunId), - ) - .sort( - (left, right) => - (runOrder.get(left.heartbeatRunId ?? "") ?? 0) - - (runOrder.get(right.heartbeatRunId ?? "") ?? 0), + let completedLeases: EnvironmentLeaseRecord[] = []; + if (execution.environment.id === "daytona") { + const retainedTurnLeases = await pollUntil({ + label: `retained Daytona leases after warm turn ${completedTurn}`, + deadlineAt: Math.min(turnDeadlineAt, Date.now() + 30_000), + intervalMs: 500, + load: () => + api.get( + `/api/environments/${fixtures!.environment.id}/leases`, + ), + accept: (leases) => { + const runOrder = new Map( + chronologicalRuns.map((candidate, index) => [ + candidate.id, + index, + ]), ); - return ( - completed.length === completedTurn && - completed - .slice(0, -1) - .every( + const completed = leases + .filter( (lease) => - lease.status === "expired" && - lease.cleanupStatus === "success", - ) && - completed.at(-1)?.status === "retained" && - completed.every( - (lease) => - lease.leasePolicy === "reuse_by_environment" && - typeof lease.providerLeaseId === "string" && - record(lease.metadata).sandboxState === "started", - ) && - completed - .slice(1) - .every( - (lease) => - record(lease.metadata).resumedFromState === "started", + lease.heartbeatRunId && + completedRunIds.has(lease.heartbeatRunId), ) + .sort( + (left, right) => + (runOrder.get(left.heartbeatRunId ?? "") ?? 0) - + (runOrder.get(right.heartbeatRunId ?? "") ?? 0), + ); + return ( + completed.length === completedTurn && + completed + .slice(0, -1) + .every( + (lease) => + lease.status === "expired" && + lease.cleanupStatus === "success", + ) && + completed.at(-1)?.status === "retained" && + completed.every( + (lease) => + lease.leasePolicy === "reuse_by_environment" && + typeof lease.providerLeaseId === "string" && + record(lease.metadata).sandboxState === "started", + ) && + completed + .slice(1) + .every( + (lease) => + record(lease.metadata).resumedFromState === "started", + ) + ); + }, + }); + const turnRunOrder = new Map( + chronologicalRuns.map((candidate, index) => [candidate.id, index]), + ); + completedLeases = retainedTurnLeases + .filter( + (lease) => + lease.heartbeatRunId && + completedRunIds.has(lease.heartbeatRunId), + ) + .sort( + (left, right) => + (turnRunOrder.get(left.heartbeatRunId ?? "") ?? 0) - + (turnRunOrder.get(right.heartbeatRunId ?? "") ?? 0), ); - }, - }); - const turnRunOrder = new Map( - chronologicalRuns.map((candidate, index) => [candidate.id, index]), - ); - const completedLeases = retainedTurnLeases - .filter( - (lease) => - lease.heartbeatRunId && - completedRunIds.has(lease.heartbeatRunId), - ) - .sort( - (left, right) => - (turnRunOrder.get(left.heartbeatRunId ?? "") ?? 0) - - (turnRunOrder.get(right.heartbeatRunId ?? "") ?? 0), - ); - if ( - new Set(completedLeases.map((lease) => lease.providerLeaseId)) - .size !== 1 - ) { - throw new Error( - `Warm turn ${completedTurn} replaced its Daytona sandbox`, - ); + if ( + new Set(completedLeases.map((lease) => lease.providerLeaseId)) + .size !== 1 + ) { + throw new Error( + `Warm turn ${completedTurn} replaced its Daytona sandbox`, + ); + } } const journalEvidence = execution.suite.id === "daytona-journal-continuity" && completedTurn === 1 ? await largeJournalEvidence({ @@ -1727,9 +1774,31 @@ for (const execution of executions) { `/${encodeURIComponent(issuePrefix)}/issues/${encodeURIComponent(issue.identifier ?? issue.id)}`, { waitUntil: "domcontentloaded" }, ); + // Navigation commits before React has loaded the task. Bind the + // screenshot to this turn's visible reply and exact pending review, + // rather than capturing a spinner after DOMContentLoaded. + const reviewUiTimeout = () => { + const remaining = turnDeadlineAt - Date.now(); + if (remaining <= 0) throw new Error(`Warm turn ${completedTurn} UI deadline elapsed`); + return Math.min(30_000, remaining); + }; + await expect(page.getByTestId("issue-detail-header").getByRole("button", { + name: "Change status (current: In Review)", exact: true, + })).toBeVisible({ timeout: reviewUiTimeout() }); + const turnReply = page.getByTestId("task-chat-thread") + .getByTestId("task-chat-agent-bubble") + .filter({ hasText: `PAPERCLIP_E2E_WARM_T${completedTurn}_${nonce}` }).last(); + await expect(turnReply).toBeVisible({ timeout: reviewUiTimeout() }); + const pendingReview = waitingState.interactions.find(isPendingWarmConfirmation)!; + const reviewCard = page.locator(`[id=${JSON.stringify(`interaction-${pendingReview.id}`)}]`); + await expect(reviewCard).toBeVisible({ timeout: reviewUiTimeout() }); + await expect(reviewCard.getByRole("button", { name: "Continue work", exact: true })) + .toBeEnabled({ timeout: reviewUiTimeout() }); + await expect(page.getByTestId("task-chat-history-loading")) + .toHaveCount(0, { timeout: reviewUiTimeout() }); await captureScreenshot( `warm-turn-${completedTurn}`, - `Warm Daytona turn ${completedTurn} awaiting review`, + `Warm ${execution.environment.id} turn ${completedTurn} awaiting review`, `warm-turn-${completedTurn}.png`, ); turnSubmissionTimesMs.push( @@ -2232,22 +2301,24 @@ for (const execution of executions) { const executionWorkspaceIds = selectedRuns.map( (candidate) => record(candidate.contextSnapshot).executionWorkspaceId, ); - if ( - leaseIds.some( - (leaseId) => typeof leaseId !== "string" || leaseId.length === 0, - ) - ) { - invariantFailures.push( - `expected a persisted Daytona lease row for every warm turn; observed ${JSON.stringify(leaseIds)}`, - ); - } - if ( - JSON.stringify(acquisitionOutcomes) !== - JSON.stringify(["created", "resumed", "resumed"]) - ) { - invariantFailures.push( - `expected warm lease outcomes created,resumed,resumed; observed ${JSON.stringify(acquisitionOutcomes)}`, - ); + if (execution.environment.id === "daytona") { + if ( + leaseIds.some( + (leaseId) => typeof leaseId !== "string" || leaseId.length === 0, + ) + ) { + invariantFailures.push( + `expected a persisted Daytona lease row for every warm turn; observed ${JSON.stringify(leaseIds)}`, + ); + } + if ( + JSON.stringify(acquisitionOutcomes) !== + JSON.stringify(["created", "resumed", "resumed"]) + ) { + invariantFailures.push( + `expected warm lease outcomes created,resumed,resumed; observed ${JSON.stringify(acquisitionOutcomes)}`, + ); + } } if ( !fixtures.project?.primaryWorkspace?.id || @@ -2330,71 +2401,76 @@ for (const execution of executions) { `warm turn timing data was incomplete or exceeded its structural deadline: ${JSON.stringify(turnTimings)}`, ); } - const retainedLeases = await pollUntil({ - label: `terminal warm Daytona lease history for issue ${issue.id}`, - deadlineAt: Math.min(deadlineAt, Date.now() + 30_000), - intervalMs: 500, - load: () => - api.get( - `/api/environments/${fixtures!.environment.id}/leases`, - ), - accept: (leases) => { - const warmLeases = leases.filter( + let warmLeases: EnvironmentLeaseRecord[] = []; + let providerLeaseIds: Array = []; + let resumedFromStates: unknown[] = []; + if (execution.environment.id === "daytona") { + const retainedLeases = await pollUntil({ + label: `terminal warm Daytona lease history for issue ${issue.id}`, + deadlineAt: Math.min(deadlineAt, Date.now() + 30_000), + intervalMs: 500, + load: () => + api.get( + `/api/environments/${fixtures!.environment.id}/leases`, + ), + accept: (leases) => { + const warmLeases = leases.filter( + (lease) => + lease.issueId === issue!.id && + selectedRuns.some( + (candidate) => candidate.id === lease.heartbeatRunId, + ), + ); + return ( + warmLeases.length === 3 && + warmLeases.every((lease) => { + const runIndex = selectedRuns.findIndex( + (candidate) => candidate.id === lease.heartbeatRunId, + ); + return ( + lease.status === + (runIndex === selectedRuns.length - 1 + ? "retained" + : "expired") && + lease.cleanupStatus === "success" && + lease.leasePolicy === "reuse_by_environment" && + typeof lease.providerLeaseId === "string" && + record(lease.metadata).sandboxState === "started" + ); + }) + ); + }, + }); + const selectedRunOrder = new Map( + selectedRuns.map((candidate, index) => [candidate.id, index]), + ); + warmLeases = retainedLeases + .filter( (lease) => lease.issueId === issue!.id && - selectedRuns.some( - (candidate) => candidate.id === lease.heartbeatRunId, - ), + selectedRunOrder.has(lease.heartbeatRunId ?? ""), + ) + .sort( + (left, right) => + (selectedRunOrder.get(left.heartbeatRunId ?? "") ?? 0) - + (selectedRunOrder.get(right.heartbeatRunId ?? "") ?? 0), ); - return ( - warmLeases.length === 3 && - warmLeases.every((lease) => { - const runIndex = selectedRuns.findIndex( - (candidate) => candidate.id === lease.heartbeatRunId, - ); - return ( - lease.status === - (runIndex === selectedRuns.length - 1 - ? "retained" - : "expired") && - lease.cleanupStatus === "success" && - lease.leasePolicy === "reuse_by_environment" && - typeof lease.providerLeaseId === "string" && - record(lease.metadata).sandboxState === "started" - ); - }) + providerLeaseIds = warmLeases.map( + (lease) => lease.providerLeaseId, + ); + resumedFromStates = warmLeases + .slice(1) + .map((lease) => record(lease.metadata).resumedFromState); + if ( + new Set(providerLeaseIds).size !== 1 || + typeof providerLeaseIds[0] !== "string" || + JSON.stringify(resumedFromStates) !== + JSON.stringify(["started", "started"]) + ) { + invariantFailures.push( + `expected one continuously-started Daytona sandbox; observed ${JSON.stringify({ providerLeaseIds, resumedFromStates })}`, ); - }, - }); - const selectedRunOrder = new Map( - selectedRuns.map((candidate, index) => [candidate.id, index]), - ); - const warmLeases = retainedLeases - .filter( - (lease) => - lease.issueId === issue!.id && - selectedRunOrder.has(lease.heartbeatRunId ?? ""), - ) - .sort( - (left, right) => - (selectedRunOrder.get(left.heartbeatRunId ?? "") ?? 0) - - (selectedRunOrder.get(right.heartbeatRunId ?? "") ?? 0), - ); - const providerLeaseIds = warmLeases.map( - (lease) => lease.providerLeaseId, - ); - const resumedFromStates = warmLeases - .slice(1) - .map((lease) => record(lease.metadata).resumedFromState); - if ( - new Set(providerLeaseIds).size !== 1 || - typeof providerLeaseIds[0] !== "string" || - JSON.stringify(resumedFromStates) !== - JSON.stringify(["started", "started"]) - ) { - invariantFailures.push( - `expected one continuously-started Daytona sandbox; observed ${JSON.stringify({ providerLeaseIds, resumedFromStates })}`, - ); + } } warmLifecycleEvidence = { ...(warmLifecycleEvidence ?? {}), @@ -2776,31 +2852,40 @@ for (const execution of executions) { }); }); try { - await cancelActiveRunsForCleanup(); - if (companyRunFlow) { - const companyRuns = await api.get(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`); - selectedRuns = await Promise.all(companyRuns.map(run => api.get(`/api/heartbeat-runs/${run.id}`))); - await writeSanitizedJson(snapshotsDir, execution.task.flow === "first_task" ? "first-task-final-run-ledger.json" : "chat-final-run-ledger.json", selectedRuns, secrets); - } - if (execution.suite.id === "stock-harness") { - try { - const stock = await captureStockHarness({ api, companyId: fixtures.company.id, - agentId: fixtures.agent.id, generation: execution.profile.generation, - runIds: selectedRuns.map(run => run.id) }); - const checks = gradeStockHarness(stock); - matcherResults.push(...checks.map(check => ({ matcher: { kind: "json_path" as const, - path: `stockHarness.${check.id}`, expected: true }, passed: check.passed, detail: check.detail }))); - await writeSanitizedJson(snapshotsDir, "stock-harness.json", { ...stock, checks }, secrets); - const failures = checks.filter(check => !check.passed); - if (failures.length && !primaryError) primaryError = new Error(`Stock harness matcher failures: ${failures.map(check => check.id).join(", ")}`); - } catch (error) { - await writeSanitizedJson(snapshotsDir, "stock-harness-evidence-error.json", { - error: error instanceof Error ? error.message : String(error), - }, secrets); - if (!primaryError) primaryError = error; - } - } - await fixtures.teardown(); + const verification = await runCleanupWithObservers({ + retireRuns: async () => { + await cancelActiveRunsForCleanup(); + if (companyRunFlow) { + const companyRuns = await api.get(`/api/companies/${fixtures!.company.id}/heartbeat-runs?limit=100`); + selectedRuns = await Promise.all(companyRuns.map(run => api.get(`/api/heartbeat-runs/${run.id}`))); + await writeSanitizedJson(snapshotsDir, execution.task.flow === "first_task" ? "first-task-final-run-ledger.json" : "chat-final-run-ledger.json", selectedRuns, secrets); + } + if (execution.suite.id === "stock-harness") { + try { + const stock = await captureStockHarness({ api, companyId: fixtures!.company.id, + agentId: fixtures!.agent.id, generation: execution.profile.generation, + runIds: selectedRuns.map(run => run.id) }); + const checks = gradeStockHarness(stock); + matcherResults.push(...checks.map(check => ({ matcher: { kind: "json_path" as const, + path: `stockHarness.${check.id}`, expected: true }, passed: check.passed, detail: check.detail }))); + await writeSanitizedJson(snapshotsDir, "stock-harness.json", { ...stock, checks }, secrets); + const failures = checks.filter(check => !check.passed); + if (failures.length && !primaryError) primaryError = new Error(`Stock harness matcher failures: ${failures.map(check => check.id).join(", ")}`); + } catch (error) { + await writeSanitizedJson(snapshotsDir, "stock-harness-evidence-error.json", { + error: error instanceof Error ? error.message : String(error), + }, secrets); + if (!primaryError) primaryError = error; + } + } + }, + assertions: beforeEnvironmentTeardownAssertions, + teardown: () => fixtures!.teardown(), + }); + matcherResults.push(...verification.checks.map(check => ({ + matcher: { kind: "json_path" as const, path: `remoteCleanup.${check.id}`, expected: true }, passed: check.passed, detail: check.detail, + }))); + if (verification.errors.length) throw new AggregateError(verification.errors, "Cleanup or remote retirement verification failed"); cleanup = "passed"; } catch (error) { cleanup = "failed"; @@ -2844,6 +2929,23 @@ for (const execution of executions) { : "passed"; } + // Local fixture teardown does not retire the API server. These assertions + // independently prove the recorded provider processes retired, while the + // workspace still exists, before final grading. The outer supervisor + // remains responsible for complete server/database cleanup. + if (cleanupAssertions.length > 0) { + const verification = await verifyCleanupAssertions(cleanupAssertions); + matcherResults.push(...verification.checks.map(check => ({ + matcher: { kind: "json_path" as const, path: `cleanup.${check.id}`, expected: true }, + passed: check.passed, detail: check.detail, + }))); + if (verification.errors.length > 0) { + cleanup = "failed"; + primaryError = new AggregateError([primaryError, ...verification.errors].filter(Boolean), "Cleanup verification failed after provider settlement"); + if (failureClassOverride !== "secret_leak") failureClassOverride = "cleanup_failure"; + } + } + const finishedAtMs = Date.now(); const runtimeUsage = buildRuntimeUsage({ environmentId: execution.environment.id, diff --git a/tests/runner-e2e/server-stop.test.ts b/tests/runner-e2e/server-stop.test.ts new file mode 100644 index 00000000000..af7e1373643 --- /dev/null +++ b/tests/runner-e2e/server-stop.test.ts @@ -0,0 +1,312 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { createRunnerE2EServerStopper, runnerE2EServerDetached } from "./server-stop.js"; +import { readProcessTable } from "./process-tree.js"; +import { createProcessTreeOwner, stopOwnedProcessTree } from "./process-tree-owner.js"; +import { runnerE2ETypeScriptProcessArgs } from "./web-server-command.js"; + +const children: ChildProcess[] = []; +const directories: string[] = []; +const worker = ` + process.once('SIGTERM', () => { + process.send('stopping'); + process.once('message', () => { + process.send('close-complete', () => process.exit(0)); + }); + }); + process.send('ready'); + setInterval(() => {}, 1000); +`; +async function fixture(source = worker) { + const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-server-stop-")); + directories.push(root); + const entry = path.join(root, "worker.cjs"); + await writeFile(entry, source); + return { root, entry }; +} +function start(entry: string) { + const child = spawn(process.execPath, [entry], { + detached: runnerE2EServerDetached, + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + children.push(child); + return child; +} +function stopper(gracefulTimeoutMs = 2_000) { + const logs: string[] = []; + const errors = new WeakSet(); + const stop = createRunnerE2EServerStopper({ + gracefulTimeoutMs, forcedTimeoutMs: 2_000, + hasSpawnError: child => errors.has(child), + markExpectedStop: () => {}, log: line => logs.push(line), + }); + return { stop, logs, errors }; +} +afterEach(async () => { + for (const child of children.splice(0)) { + if (child.exitCode !== null || child.signalCode !== null) continue; + const exited = once(child, "exit").catch(() => {}); + try { + if (runnerE2EServerDetached && child.pid) process.kill(-child.pid, "SIGKILL"); + else child.kill("SIGKILL"); + } catch { child.kill("SIGKILL"); } + await exited; + } + for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }); +}); + +it.skipIf(process.platform === "win32")("joins signal, final cleanup and repeated calls while a once-handler finishes asynchronous close", async () => { + const { entry } = await fixture(); + const child = start(entry); + expect((await once(child, "message"))[0]).toBe("ready"); + const { stop, logs } = stopper(); + const stopping = once(child, "message"); + const first = stop(child); + expect((await stopping)[0]).toBe("stopping"); + // The server supervisor reaches final cleanup after its 200ms poll. + await new Promise(resolve => setTimeout(resolve, 250)); + const messages: unknown[] = []; + child.on("message", message => messages.push(message)); + const finalCleanup = stop(child); + const repeated = stop(child, "SIGINT"); + // Give an erroneous repeated signal time to interrupt the held close before + // releasing it. A behavioral failure must not depend on promise identity. + await new Promise(resolve => setTimeout(resolve, 50)); + if (child.connected) child.send("finish"); + await Promise.all([first, finalCleanup, repeated]); + expect(messages).toContain("close-complete"); + expect([child.exitCode, child.signalCode]).toEqual([0, null]); + expect(finalCleanup).toBe(first); + expect(repeated).toBe(first); + expect(stop(child)).toBe(first); + await stop(child); + expect(logs).toEqual([]); +}); + +it.skipIf(process.platform === "win32")("bounds an unresponsive child and reaps it with one escalation", async () => { + const { entry } = await fixture(`process.on('SIGTERM', () => {}); process.send('ready'); setInterval(() => {}, 1000);`); + const child = start(entry); + await once(child, "message"); + const { stop, logs } = stopper(50); + await Promise.all([stop(child), stop(child), stop(child)]); + expect(child.signalCode).toBe("SIGKILL"); + expect(logs).toHaveLength(1); + expect(() => process.kill(child.pid!, 0)).toThrow(); +}); + +it("settles an already-exited child without signals or escalation", async () => { + const { entry } = await fixture("process.exit(0)"); + const child = start(entry); + await once(child, "exit"); + const { stop, logs } = stopper(); + await stop(child); + expect([child.exitCode, child.signalCode, logs]).toEqual([0, null, []]); +}); + +it("settles a failed spawn without attempting escalation", async () => { + const { root } = await fixture(); + const child = spawn(path.join(root, "missing-server-executable")); + const { stop, logs, errors } = stopper(); + child.once("error", () => errors.add(child)); + await new Promise(resolve => child.once("error", () => resolve())); + await stop(child); + expect(logs).toEqual([]); +}); + +it.skipIf(process.platform === "win32")("lets launcher cancellation join wrapper shutdown without signaling the server twice", async () => { + const { root, entry } = await fixture(); + const supervisor = path.join(root, "supervisor.mts"); + const helper = path.join(import.meta.dirname, "server-stop.ts"); + await writeFile(supervisor, ` + import { spawn } from 'node:child_process'; + import { createRunnerE2EServerStopper, runnerE2EServerDetached } from ${JSON.stringify(helper)}; + const child = spawn(process.execPath, [${JSON.stringify(entry)}], { + detached: runnerE2EServerDetached, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }); + const stop = createRunnerE2EServerStopper({ gracefulTimeoutMs: 2000, forcedTimeoutMs: 2000, + hasSpawnError: () => false, markExpectedStop: () => {}, log: () => {} }); + const finish = () => { void stop(child).then(() => { + process.send!({ exitCode: child.exitCode, signalCode: child.signalCode }, () => process.exit(0)); + }); }; + process.on('SIGTERM', finish); + process.on('message', message => { if (message === 'stop') finish(); else child.send('finish'); }); + child.on('message', message => process.send!({ message, pid: child.pid })); + `); + const wrapper = spawn(process.execPath, runnerE2ETypeScriptProcessArgs(path.resolve(import.meta.dirname, "../.."), supervisor), { + detached: true, stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + children.push(wrapper); + let wrapperErrors = ""; + wrapper.stderr?.on("data", chunk => { wrapperErrors += chunk; }); + let workerPid: number | undefined; + const owner = createProcessTreeOwner(wrapper); + try { + workerPid = (await once(wrapper, "message"))[0].pid; + const stopping = once(wrapper, "message"); + wrapper.send("stop"); + expect((await stopping)[0].message).toBe("stopping"); + // If Paperclip shared the wrapper group, this would kill its once-handler + // during the async close even though the helper sends only one signal. + const messages: unknown[] = []; + wrapper.on("message", message => messages.push(message)); + const exited = once(wrapper, "exit"); + await owner.observe(); + const cancellation = stopOwnedProcessTree(wrapper, owner, 2_000, 2_000); + await new Promise(resolve => setTimeout(resolve, 250)); + if (wrapper.connected) wrapper.send("finish"); + await exited; + await cancellation; + expect(messages, wrapperErrors).toContainEqual({ message: "close-complete", pid: workerPid }); + expect(messages).toContainEqual({ exitCode: 0, signalCode: null }); + expect(() => process.kill(workerPid!, 0)).toThrow(); + } finally { + owner.stopObserving(); + if (workerPid) { try { process.kill(-workerPid, "SIGKILL"); } catch { /* reaped */ } } + } +}); + + +it.skipIf(process.platform === "win32")("escalates the owned server group so a hung descendant cannot remain", async () => { + const { entry } = await fixture(` + const { spawn } = require('node:child_process'); + const descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }); + process.on('SIGTERM', () => {}); + process.send({ pid: descendant.pid }); + setInterval(() => {}, 1000); + `); + const child = start(entry); + const pid = (await once(child, "message"))[0].pid as number; + try { + await stopper(50).stop(child); + expect(child.signalCode).toBe("SIGKILL"); + // A stopped orphan may remain a zombie until the host's init reaps it. + const table = await readProcessTable(); + expect(table).not.toBeNull(); + expect(table!.some(row => row.pid === pid && !row.state?.startsWith("Z"))).toBe(false); + } finally { + try { process.kill(pid, "SIGKILL"); } catch { /* reaped */ } + } +}); + + +it.skipIf(process.platform === "win32")("retires observed descendants after the server leader exits early", async () => { + const { root, entry } = await fixture(); + const marker = path.join(root, "descendant-closed"); + const descendant = ` + process.once('SIGTERM', () => setTimeout(() => { + require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'closed'); process.exit(0); + }, 50)); + process.send('ready'); setInterval(() => {}, 1000); + `; + await writeFile(entry, ` + const child = require('node:child_process').spawn(process.execPath, ['-e', ${JSON.stringify(descendant)}], { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }); + child.once('message', () => process.send({ pid: child.pid })); + process.once('message', () => process.exit(1)); + `); + const child = start(entry); + const pid = (await once(child, "message"))[0].pid as number; + const { stop } = stopper(); + try { + await stop.watch(child); + const exited = once(child, "exit"); + child.send("crash"); + await exited; + await stop(child); + expect(await readFile(marker, "utf8")).toBe("closed"); + const table = await readProcessTable(); + expect(table).not.toBeNull(); + expect(table!.some(row => row.pid === pid && !row.state?.startsWith("Z"))).toBe(false); + } finally { try { process.kill(pid, "SIGKILL"); } catch { /* stopped */ } } +}); + +it.skipIf(process.platform === "win32")("recovers a failed cleanup without repeating the graceful signal", async () => { + const { entry } = await fixture(); + const child = start(entry); + await once(child, "message"); + let failInspection = false; + let owner: ReturnType | undefined; + const messages: unknown[] = []; + child.on("message", message => { + messages.push(message); + if (message === "stopping") failInspection = true; + }); + const stop = createRunnerE2EServerStopper({ + gracefulTimeoutMs: 2_000, forcedTimeoutMs: 2_000, + hasSpawnError: () => false, markExpectedStop: () => {}, log: () => {}, + createOwner: candidate => { + owner = createProcessTreeOwner(candidate); + return { ...owner, observe: async () => { + if (failInspection) { failInspection = false; throw new Error("transient inspection failure"); } + await owner!.observe(); + } }; + }, + }); + try { + const failed = expect(stop(child)).rejects.toThrow("transient inspection failure"); + await new Promise(resolve => setTimeout(resolve, 100)); + if (child.connected) child.send("finish"); + await failed; + const recovery = stop(child); + // A second graceful signal would terminate this child's once-handler. + await new Promise(resolve => setTimeout(resolve, 50)); + if (child.connected) child.send("finish"); + await recovery; + expect(messages).toEqual(["stopping", "close-complete"]); + expect([child.exitCode, child.signalCode]).toEqual([0, null]); + } finally { owner?.stopObserving(); } +}); + +it.skipIf(process.platform === 'win32').each([['launcher', 'null'], ['server', 'null'], ['launcher', 'throw'], ['server', 'throw']] as const)( + 'bounds the known %s child when inspection fails with %s', async (kind, failure) => { + const { entry } = await fixture(`process.on('SIGTERM', () => {}); process.send('ready'); setInterval(() => {}, 1000);`); + const child = start(entry); + await once(child, 'message'); + const groupSignals: number[] = []; + const owner = createProcessTreeOwner(child, { + readTable: async () => { if (failure === "throw") throw new Error("inspection failed"); return null; }, + signalGroup: pid => { groupSignals.push(pid); }, + }); + try { + const stopping = kind === 'launcher' + ? stopOwnedProcessTree(child, owner, 50, 500) + : createRunnerE2EServerStopper({ gracefulTimeoutMs: 50, forcedTimeoutMs: 500, + hasSpawnError: () => false, markExpectedStop: () => {}, log: () => {}, createOwner: () => owner })(child); + await expect(stopping).rejects.toThrow(/inspect|incomplete/i); + expect(child.signalCode).toBe('SIGKILL'); + expect(groupSignals).toEqual([]); + } finally { owner.stopObserving(); } + }, +); + +it.skipIf(process.platform === 'win32')('does not re-signal a server after its graceful wrapper forwards and exits', async () => { + const { root, entry } = await fixture(); + const marker = path.join(root, 'async-close-finished'); + await writeFile(entry, ` + const child = require('node:child_process').spawn(process.execPath, ['-e', ${JSON.stringify(` + process.once('SIGTERM', () => setTimeout(() => { + require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'closed'); process.exit(0); + }, 300)); + process.send('ready'); setInterval(() => {}, 1000); + `)}], { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }); + child.once('message', () => process.send({ pid: child.pid })); + process.once('SIGTERM', () => { child.kill('SIGTERM'); process.exit(0); }); + `); + const wrapper = start(entry); + const pid = (await once(wrapper, 'message'))[0].pid as number; + const owner = createProcessTreeOwner(wrapper); + try { + await owner.observe(); + await stopOwnedProcessTree(wrapper, owner, 2_000, 500); + expect(await readFile(marker, 'utf8')).toBe('closed'); + expect(wrapper.exitCode).toBe(0); + } finally { + owner.stopObserving(); + try { process.kill(-pid, 'SIGKILL'); } catch { /* already stopped */ } + } +}); diff --git a/tests/runner-e2e/server-stop.ts b/tests/runner-e2e/server-stop.ts new file mode 100644 index 00000000000..bbeddc734c1 --- /dev/null +++ b/tests/runner-e2e/server-stop.ts @@ -0,0 +1,81 @@ +import type { ChildProcess } from "node:child_process"; +import { createProcessTreeOwner, incompleteTreeFallback } from "./process-tree-owner.js"; + +// The wrapper receives Playwright's group signal; only it signals Paperclip. +export const runnerE2EServerDetached = process.platform !== "win32"; +const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export function createRunnerE2EServerStopper(options: { + gracefulTimeoutMs: number; + forcedTimeoutMs: number; + hasSpawnError: (child: ChildProcess) => boolean; + markExpectedStop: (child: ChildProcess) => void; + log: (message: string) => void; + createOwner?: typeof createProcessTreeOwner; +}) { + type State = { + owner: ReturnType; + promise?: Promise; + gracefulDeadline?: number; + forcedDeadline?: number; + gracefulSent: boolean; + groupSignals: Set; + escalationLogged: boolean; + }; + const states = new WeakMap(); + const exited = (child: ChildProcess) => child.exitCode !== null || child.signalCode !== null || options.hasSpawnError(child); + function watch(child: ChildProcess) { + let state = states.get(child); + if (!state) { + state = { owner: (options.createOwner ?? createProcessTreeOwner)(child), gracefulSent: false, groupSignals: new Set(), escalationLogged: false }; + states.set(child, state); + } + return state; + } + async function stopOnce(child: ChildProcess, state: State, signal: NodeJS.Signals) { + state.gracefulDeadline ??= Date.now() + options.gracefulTimeoutMs; + await state.owner.observe(); + if (!exited(child) && !state.gracefulSent) { + state.gracefulSent = child.kill(signal); + } + while (Date.now() < state.gracefulDeadline) { + await state.owner.observe(); + if (exited(child)) { + if (!state.owner.liveGroups().length) { state.owner.stopObserving(); return; } + // The leader is gone. Retire only descendants whose start identities + // we observed while they belonged to this server, including private groups. + const unsignaled = new Set(state.owner.liveGroups().map(group => group.processGroupId).filter(pid => !state.groupSignals.has(pid))); + for (const pid of await state.owner.signal("SIGTERM", unsignaled)) state.groupSignals.add(pid); + } + await wait(25); + } + if (!state.escalationLogged) { + options.log(`\nPaperclip did not stop within ${options.gracefulTimeoutMs}ms; sending SIGKILL\n`); + state.escalationLogged = true; + } + if (runnerE2EServerDetached) await state.owner.signal("SIGKILL"); + else if (!exited(child)) child.kill("SIGKILL"); + state.forcedDeadline ??= Date.now() + options.forcedTimeoutMs; + do { + await state.owner.observe(); + if (exited(child) && !state.owner.liveGroups().length) { state.owner.stopObserving(); return; } + await wait(25); + } while (Date.now() < state.forcedDeadline); + throw new Error("Paperclip server or observed descendants did not exit after SIGKILL"); + } + const stop = (child: ChildProcess, signal: NodeJS.Signals = "SIGTERM"): Promise => { + options.markExpectedStop(child); + const state = watch(child); + state.promise ??= Promise.resolve().then(() => stopOnce(child, state, signal)).catch(async error => { + try { + await incompleteTreeFallback(error, child, state, state.gracefulDeadline!, options.forcedTimeoutMs); + } finally { + state.promise = undefined; + } + }); + /* Retain the signal/deadline phase so a later audit can recover without + repeating the graceful signal. */ + return state.promise; + }; + return Object.assign(stop, { watch: (child: ChildProcess) => watch(child).owner.observe() }); +} diff --git a/tests/runner-e2e/server.ts b/tests/runner-e2e/server.ts index 4a3053b3ddb..9acb4292a25 100644 --- a/tests/runner-e2e/server.ts +++ b/tests/runner-e2e/server.ts @@ -1,3 +1,4 @@ +import { createRunnerE2EServerStopper, runnerE2EServerDetached } from "./server-stop.js"; import { runnerE2ETypeScriptProcessArgs } from "./web-server-command.js"; import { qualifyLegacyClaudeCli } from "./legacy-claude-cli.js"; import { spawn, type ChildProcess } from "node:child_process"; @@ -105,7 +106,7 @@ function describeChildExit(candidate: ChildProcess) { return `server exited code=${String(candidate.exitCode)} signal=${String(candidate.signalCode)}`; } -function startServer() { +async function startServer() { if (shutdownRequested()) { throw new Error("Refusing to start Paperclip after wrapper shutdown"); } @@ -116,10 +117,9 @@ function startServer() { cwd: repositoryRoot, env: definedServerEnvironment, stdio: ["ignore", "pipe", "pipe"], - // Stay in the launcher-created process group. That lets the launcher stop - // Playwright, this wrapper, Paperclip, embedded Postgres, and runner children - // as one verified tree even if graceful web-server shutdown stalls. - detached: false, + // Playwright signals the wrapper's group. Keep that signal from bypassing + // our single graceful stop; the launcher still tracks descendant groups. + detached: runnerE2EServerDetached, }, ); child = candidate; @@ -152,13 +152,10 @@ function startServer() { // A shutdown may arrive in the synchronous interval around spawn. Never let // that race create an unowned replacement server. if (shutdownSignal) { - expectedStops.add(candidate); - try { - candidate.kill(shutdownSignal); - } catch { - // The process may have failed during spawn. - } + // The main/error path awaits this same promise and reports any failure. + void stopServer(candidate, shutdownSignal).catch(() => {}); } + await stopServer.watch(candidate); return candidate; } @@ -166,53 +163,13 @@ function delay(milliseconds: number) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } -async function waitForExit(candidate: ChildProcess, timeoutMs: number) { - if (childExited(candidate) || childErrors.has(candidate)) return true; - return await new Promise((resolve) => { - let settled = false; - const finish = (exited: boolean) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - candidate.off("exit", onExit); - candidate.off("error", onError); - resolve(exited); - }; - const onExit = () => finish(true); - const onError = () => finish(true); - const timeout = setTimeout(() => finish(false), timeoutMs); - candidate.once("exit", onExit); - candidate.once("error", onError); - }); -} - -async function stopServer( - candidate: ChildProcess, - signal: NodeJS.Signals = "SIGTERM", -) { - expectedStops.add(candidate); - if (childExited(candidate) || childErrors.has(candidate)) return; - try { - candidate.kill(signal); - } catch { - if (childExited(candidate) || childErrors.has(candidate)) return; - throw new Error("Could not signal the Paperclip server to stop"); - } - if (await waitForExit(candidate, gracefulStopTimeoutMs)) return; - - appendLog( - `\nPaperclip did not stop within ${gracefulStopTimeoutMs}ms; sending SIGKILL\n`, - ); - try { - candidate.kill("SIGKILL"); - } catch { - if (childExited(candidate) || childErrors.has(candidate)) return; - throw new Error("Could not force the Paperclip server to stop"); - } - if (!(await waitForExit(candidate, 5_000))) { - throw new Error("Paperclip server did not exit after SIGKILL"); - } -} +const stopServer = createRunnerE2EServerStopper({ + gracefulTimeoutMs: gracefulStopTimeoutMs, + forcedTimeoutMs: 5_000, + hasSpawnError: (candidate) => childErrors.has(candidate), + markExpectedStop: (candidate) => { expectedStops.add(candidate); }, + log: appendLog, +}); async function waitForHealth(candidate: ChildProcess) { const deadline = Date.now() + restartTimeoutMs; @@ -324,7 +281,7 @@ async function restartServer(requestId: string) { } appendLog(`Restart request ${requestId}: starting Paperclip\n`); - const replacement = startServer(); + const replacement = await startServer(); await waitForHealth(replacement); if (shutdownRequested()) { throw new Error("Wrapper shutdown interrupted the Paperclip restart"); @@ -339,12 +296,9 @@ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { if (shutdownSignal) return; shutdownSignal = signal; if (!child) return; - expectedStops.add(child); - try { - child.kill(signal); - } catch { - // The Paperclip process may already have exited. - } + // Begin immediately, including during a health/restart wait. The final + // cleanup joins this promise instead of sending a second graceful signal. + void stopServer(child, signal).catch(() => {}); }); } @@ -360,7 +314,7 @@ async function supervise() { }); // Postgres needs the socket itself; release immediately before child spawn. await databaseReservation?.close(); - startServer(); + await startServer(); let lastRestartRequestId: string | null = null; while (!shutdownRequested()) { if (unexpectedChildFailure) throw unexpectedChildFailure; diff --git a/tests/runner-e2e/types.ts b/tests/runner-e2e/types.ts index b0e3c1608a9..5920929f25f 100644 --- a/tests/runner-e2e/types.ts +++ b/tests/runner-e2e/types.ts @@ -29,7 +29,9 @@ export type RunnerTaskFlow = | "question_resume_completion" | "plan_approval_completion" | "warm_three_turn" - | "instruction_persistence"; + | "instruction_persistence" + | "native_active_stop" + | "cursor_native"; export interface SecretReference { type: "secret_ref"; diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index bb737ee45ce..b1c21dfef1c 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -218,11 +218,13 @@ export function CodexLocalConfigFields({ adapterSchemaValues: { ...values!.adapterSchemaValues, provider, + acpxSessionMode: undefined, ...(provider === "acpx" ? { acpxAgent: grok ? "grok" : "claude" } : {}), }, }); } else { mark("adapterConfig", "provider", provider); + mark("adapterConfig", "acpxSessionMode", undefined); mark("adapterConfig", "model", model); if (provider === "acpx") { mark("adapterConfig", "acpxAgent", grok ? "grok" : "claude"); @@ -245,10 +247,11 @@ export function CodexLocalConfigFields({ value={String(isCreate ? values!.adapterSchemaValues?.acpxAgent ?? "claude" : eff("adapterConfig", "acpxAgent", config.acpxAgent ?? "claude"))} onChange={(event) => { const profile = PAPERCLIP_RUNNER_ACPX_PROFILES.find(entry => entry.value === event.target.value); + const acpxSessionMode = profile?.value === "cursor" ? "agent" : undefined; if (!profile?.qualified) return; if (isCreate) set!({ model: profile.value === "claude" ? defaultAcpxClaudeModel : "", - adapterSchemaValues: { ...values!.adapterSchemaValues, acpxAgent: profile.value } }); - else { mark("adapterConfig", "acpxAgent", profile.value); mark("adapterConfig", "model", profile.value === "claude" ? defaultAcpxClaudeModel : ""); } + adapterSchemaValues: { ...values!.adapterSchemaValues, acpxAgent: profile.value, acpxSessionMode } }); + else { mark("adapterConfig", "acpxAgent", profile.value); mark("adapterConfig", "acpxSessionMode", acpxSessionMode); mark("adapterConfig", "model", profile.value === "claude" ? defaultAcpxClaudeModel : ""); } }}> {PAPERCLIP_RUNNER_ACPX_PROFILES.map(profile =>