diff --git a/.dockerignore b/.dockerignore index 7aaa533fe7..597c1fa5ca 100644 --- a/.dockerignore +++ b/.dockerignore @@ -36,6 +36,19 @@ packages/paperclip-runner/scripts/*-smoke.mjs # on master failed its drift check — .github/docker-context-checks.Dockerfile # now guards this in PR CI. !packages/paperclip-runner/generated/** +# ACPX generation verifies these immutable release attestations as build inputs. +# Keep the two declarations while excluding other development fixtures. +!packages/paperclip-runner/test +packages/paperclip-runner/test/** +!packages/paperclip-runner/test/fixtures +packages/paperclip-runner/test/fixtures/** +!packages/paperclip-runner/test/fixtures/copilot-profile-v16-identity.json +!packages/paperclip-runner/test-fixtures +packages/paperclip-runner/test-fixtures/** +!packages/paperclip-runner/test-fixtures/pi-acp +packages/paperclip-runner/test-fixtures/pi-acp/** +!packages/paperclip-runner/test-fixtures/pi-acp/profile-v18-identity.json +!packages/paperclip-runner/test-fixtures/pi-acp/profile-v19-identity.json !packages/paperclip-runner/docs/capability-contract.md # check:runner-workflow-traceability access()es every regression test the # stress-traceability spec names — those are src/**/*.test.ts files, so diff --git a/.github/docker-context-checks.Dockerfile b/.github/docker-context-checks.Dockerfile index b259714bb7..4ceba5dd64 100644 --- a/.github/docker-context-checks.Dockerfile +++ b/.github/docker-context-checks.Dockerfile @@ -22,6 +22,8 @@ FROM node:24-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e WORKDIR /context COPY . . +# Verify ACPX release declarations against the exact Docker context. +RUN node packages/paperclip-runner/scripts/generate-acpx-profiles.mjs --check # Committed artifacts the image build reads whose drift checks cannot run # here (they need the locked dependency tree or compiled dist/). Existence # in the context is the property this probe guards; content correctness is diff --git a/.github/workflows/docker-runner-check.yml b/.github/workflows/docker-runner-check.yml index 39d148a792..796851be13 100644 --- a/.github/workflows/docker-runner-check.yml +++ b/.github/workflows/docker-runner-check.yml @@ -8,19 +8,19 @@ on: type: string required: false publish_eval_image: - description: "Publish an immutable Daytona qualification image on the EC2 fleet (no provider credentials)" + description: "Publish an immutable Daytona qualification image on hosted Linux (no provider credentials)" type: boolean default: false verify_source: - description: "Run broad source checks on EC2" + description: "Run broad source checks on hosted Linux" type: boolean default: false verify_public_install: - description: "Build and verify clean public npm installation on EC2 (no provider credentials)" + description: "Build and verify clean public npm installation on hosted Linux (no provider credentials)" type: boolean default: false build_eval_viewer: - description: "Build the canonical eval report viewer on EC2" + description: "Build the canonical eval report viewer on hosted Linux" type: boolean default: false pull_request: @@ -98,9 +98,10 @@ jobs: echo "target_sha=$target_sha" >> "$GITHUB_OUTPUT" manual_image: - name: Build and verify on EC2 + name: Build and verify on Linux needs: authorize_manual - runs-on: runs-on/fleet=paperclip-public-pr-x64/env=public-ci + # Keep maintainer authorization below; use a native hosted Linux builder. + runs-on: ubuntu-latest timeout-minutes: 90 permissions: contents: read @@ -169,9 +170,9 @@ jobs: docker buildx imagetools inspect "$immutable" >/dev/null echo "$immutable" > remote-verification/image.txt echo "Image: $immutable" >> "$GITHUB_STEP_SUMMARY" - - name: Verify repository on EC2 + - name: Verify repository on hosted Linux if: inputs.verify_source - # Leave time for artifact retention before the fleet's one-hour lifetime. + # Leave time for artifact retention within the job deadline. timeout-minutes: 38 run: | set -uo pipefail @@ -195,7 +196,8 @@ jobs: test -f scripts/verify-grok-npm-install.mjs || { echo "Selected source does not provide the public-install verifier"; exit 1; } pnpm install --frozen-lockfile --ignore-scripts > remote-verification/npm-setup.log 2>&1 pnpm build > remote-verification/npm-build.log 2>&1 - node scripts/verify-grok-npm-install.mjs > remote-verification/public-npm-install.log 2>&1 + PAPERCLIP_RUNNER_QUALIFICATION_PACKAGES_DIR="$PWD/remote-verification/qualification-packages" \ + node scripts/verify-grok-npm-install.mjs > remote-verification/public-npm-install.log 2>&1 - name: Build canonical eval report viewer if: always() && (inputs.verify_source || inputs.build_eval_viewer) run: | diff --git a/cli/src/__tests__/runtime.test.ts b/cli/src/__tests__/runtime.test.ts new file mode 100644 index 0000000000..eeed2d2620 --- /dev/null +++ b/cli/src/__tests__/runtime.test.ts @@ -0,0 +1,58 @@ +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"; +import { Command } from "commander"; +import { afterEach, expect, it } from "vitest"; +import { buildPiSetupEnvironment, registerRuntimeCommands, resolvePiProvisioner, resolveRemoteCompanionImporter } 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(), "paperclip-cli-runtime-"))); roots.push(root); + const cli = join(root, "dist/vendor/paperclip-runner/cli"); await mkdir(cli, { recursive: true }); + await writeFile(join(root, "package.json"), '{"name":"@paperclipai/server"}'); + await writeFile(join(root, "dist/index.js"), "throw new Error('server must not start during setup resolution')"); + await writeFile(join(cli, "provision-pi.cjs"), "fixture"); + return { root, cli, url: pathToFileURL(join(root, "dist/index.js")).href }; +} +it("locates the public server's setup entrypoint without importing its API server", async () => { + const f = await fixture(); expect(await resolvePiProvisioner(f.url)).toBe(join(f.cli, "provision-pi.cjs")); +}); +it("rejects foreign package identity and a setup entrypoint outside that package", async () => { + const f = await fixture(); const other = await fixture(); + await writeFile(join(f.root, "package.json"), '{"name":"foreign"}'); + await expect(resolvePiProvisioner(f.url)).rejects.toThrow("identity"); + await writeFile(join(f.root, "package.json"), '{"name":"@paperclipai/server"}'); + await rm(join(f.cli, "provision-pi.cjs")); await symlink(join(other.cli, "provision-pi.cjs"), join(f.cli, "provision-pi.cjs")); + await expect(resolvePiProvisioner(f.url)).rejects.toThrow("escapes"); +}); +it("rejects unsupported runtime setup providers before resolution", async () => { + const program = new Command(); registerRuntimeCommands(program); + await expect(program.parseAsync(["node", "paperclipai", "runtime", "setup", "untrusted"])).rejects.toThrow("Supported runtime setup: paperclipai runtime setup pi or cursor"); +}); + +it("resolves the companion importer only inside the actual public server tar layout", async () => { + const f = await fixture(); const path = join(f.root, "dist/services/native-runtime/remote-pi-companion.js"); + await mkdir(join(f.root, "dist/services/native-runtime"), { recursive: true }); + await writeFile(path, "throw new Error('resolution must not execute importer')"); + expect(await resolveRemoteCompanionImporter(f.url)).toBe(path); + const other = await fixture(); await rm(path); await symlink(join(other.cli, "provision-pi.cjs"), path); + await expect(resolveRemoteCompanionImporter(f.url)).rejects.toThrow("escapes"); +}); +it("requires an explicit digest for the companion import command", async () => { + const program = new Command(); program.exitOverride().configureOutput({ writeErr() {} }); registerRuntimeCommands(program); + await expect(program.parseAsync(["node", "paperclipai", "runtime", "import-remote", "/unused"])).rejects.toThrow("sha256"); +}); + +it("passes explicit setup network settings without provider keys or ambient Node/npm configuration", () => { + const network = { PATH: "/public/bin", LC_ALL: "C.UTF-8", HTTPS_PROXY: "http://proxy.example:8080", HTTP_PROXY: "http://proxy.example:8080", NO_PROXY: "localhost", SSL_CERT_FILE: "/public/ca.pem", SSL_CERT_DIR: "/public/certs", NODE_EXTRA_CA_CERTS: "/public/extra-ca.pem" }; + const environment = buildPiSetupEnvironment({ ...network, LANG: "foreign", HOME: "/private/home", OPENROUTER_API_KEY: "must-not-forward", ANTHROPIC_API_KEY: "must-not-forward", NPM_TOKEN: "must-not-forward", npm_config_registry: "https://foreign.example", NODE_OPTIONS: "--require /foreign.js", NODE_PATH: "/foreign/modules", NODE_TLS_REJECT_UNAUTHORIZED: "0" }); + expect(environment).toEqual({ ...network, LANG: "C.UTF-8" }); + expect(buildPiSetupEnvironment({})).toEqual({ PATH: "/usr/bin:/bin", LANG: "C.UTF-8" }); +}); + +it("preserves lowercase proxy settings and leaves precedence to Node/npm", () => { + const lower = { http_proxy: "http://lower-proxy.example:8080", https_proxy: "http://lower-proxy.example:8080", no_proxy: "localhost" }; + expect(buildPiSetupEnvironment(lower)).toEqual({ PATH: "/usr/bin:/bin", LANG: "C.UTF-8", ...lower }); + expect(buildPiSetupEnvironment({ ...lower, HTTPS_PROXY: "http://upper-proxy.example:8080", OPENROUTER_API_KEY: "must-not-forward" })).toEqual({ PATH: "/usr/bin:/bin", LANG: "C.UTF-8", ...lower, HTTPS_PROXY: "http://upper-proxy.example:8080" }); +}); diff --git a/cli/src/commands/runtime.ts b/cli/src/commands/runtime.ts index 5afdc54be8..ee9b1afdc4 100644 --- a/cli/src/commands/runtime.ts +++ b/cli/src/commands/runtime.ts @@ -1,9 +1,63 @@ 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 { fileURLToPath, pathToFileURL } from "node:url"; import type { Command } from "commander"; +/** Resolve the public server dependency, without importing/starting the server. */ +export async function resolvePiProvisioner(serverUrl: string): Promise { + const url = new URL(serverUrl); + if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Pi setup requires an installed Paperclip server"); + const entry = await realpath(fileURLToPath(url)); + if (!entry.endsWith("/dist/index.js")) throw new Error("Pi 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("Pi setup server package identity is invalid"); + const provisioner = join(root, "dist/vendor/paperclip-runner/cli/provision-pi.cjs"); + if (await realpath(provisioner) !== provisioner || !(await lstat(provisioner)).isFile()) throw new Error("Pi setup entrypoint escapes its server package"); + return provisioner; +} + +export async function resolveRemoteCompanionImporter(serverUrl: string): Promise { + const provisioner = await resolvePiProvisioner(serverUrl); + const serverRoot = resolve(dirname(provisioner), "../../../.."); + const modulePath = join(serverRoot, "dist/services/native-runtime/remote-pi-companion.js"); + if (await realpath(modulePath) !== modulePath || !(await lstat(modulePath)).isFile()) throw new Error("Remote companion importer escapes its installed server"); + return modulePath; +} + +/** Public downloads may use operator network settings, never application secrets. */ +export function buildPiSetupEnvironment(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const environment: NodeJS.ProcessEnv = { PATH: source.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8" }; + for (const key of ["LC_ALL", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "https_proxy", "http_proxy", "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"]) { + if (typeof source[key] === "string") environment[key] = source[key]; + } + return environment; +} + +export async function setupPiRuntime(): Promise { + const provisioner = await resolvePiProvisioner(import.meta.resolve("@paperclipai/server")); + // Only explicit setup downloads. Enable Node's proxy handling for the helper; + // provider keys, npm configuration, HOME and Node injection remain excluded. + const child = spawn(process.execPath, ["--use-env-proxy", provisioner], { + stdio: "inherit", env: buildPiSetupEnvironment(), + }); + 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("Pi setup did not finish. Review its error; an existing invalid installation is never replaced automatically.")); + else accept(); + }); + }); + } finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); } +} + /** Resolve the installed public dependency without importing or starting it. */ export async function resolveCursorProvisioner(serverUrl: string): Promise { const url = new URL(serverUrl); @@ -39,12 +93,29 @@ export async function setupCursorRuntime(): Promise { } 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)") + const runtime = program.command("runtime").description("Manage explicitly installed agent runtimes"); + runtime.command("import-remote ") + .description("Import a verified Linux Pi companion into the installed server (no downloads)") + .requiredOption("--sha256 ", "SHA256 of companion.json from the trusted release") + .action(async (directory: string, options: { sha256: string }) => { + const modulePath = await resolveRemoteCompanionImporter(import.meta.resolve("@paperclipai/server")); + const importer = await import(pathToFileURL(modulePath).href) as { importRemotePiCompanion(input: { directory: string; sha256: string; checkCancelled: () => void }): Promise }; + let cancelled = false; + const cancel = () => { cancelled = true; }; + process.on("SIGINT", cancel); process.on("SIGTERM", cancel); + try { + const result = await importer.importRemotePiCompanion({ directory, sha256: options.sha256, + checkCancelled: () => { if (cancelled) throw new Error("Remote companion import cancelled"); } }); + console.log(JSON.stringify(result)); + } finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); } + }); + runtime.command("setup ") + .description("Install and verify the pinned Pi or 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(); + if (provider === "pi") await setupPiRuntime(); + else if (provider === "cursor") await setupCursorRuntime(); + else throw new Error("Supported runtime setup: paperclipai runtime setup pi or cursor"); }); } diff --git a/cli/src/index.ts b/cli/src/index.ts index 022b19d5b7..cf3450cfc2 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,3 +1,4 @@ +import { registerRuntimeCommands } from "./commands/runtime.js"; import { registerEmailCommands } from "./commands/client/email.js"; import { registerMcpCommands } from "./commands/mcp.js"; import { Command } from "commander"; @@ -32,7 +33,6 @@ 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"; @@ -103,6 +103,7 @@ program .action(updateCommand); program.hook("preAction", async (_thisCommand, actionCommand) => { + if (actionCommand.parent?.name() === "runtime") return; // Public runtime setup never reads instance config or credentials. const options = actionCommand.optsWithGlobals() as DataDirOptionLike & TestDriveOptions; let dataDirOptions: DataDirOptionLike = options; if (actionCommand.name() === "test-drive") { @@ -126,6 +127,7 @@ program.hook("preAction", async (_thisCommand, actionCommand) => { }); registerTestDriveCommand(program); +registerRuntimeCommands(program); program .command("onboard") @@ -262,7 +264,6 @@ registerSecretCommands(program); registerSkillsCommands(program); registerTeamCommands(program); registerWorktreeCommands(program); -registerRuntimeCommands(program); registerEnvLabCommands(program); registerPluginCommands(program); diff --git a/docker/daytona-runner/Dockerfile b/docker/daytona-runner/Dockerfile index b1a301d4c8..01fff8dce6 100644 --- a/docker/daytona-runner/Dockerfile +++ b/docker/daytona-runner/Dockerfile @@ -33,7 +33,7 @@ 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=d3c4cffe7d127d99789cf354c5275246526f5396d2a86ce0d387352f68a2394b +ARG PAPERCLIP_RUNNER_LOCK_SHA256=440f0ccd8a383b32a576dc53b4191d73bb03a91ba0dabfa1c3d79c3039e01aa5 # 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()')" \ diff --git a/package.json b/package.json index d2b9ceaa2b..d37129f05e 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "smoke:posthog-live": "node scripts/smoke/posthog-live.mjs", "smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh", "smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs", - "test:release-registry": "node --test scripts/__tests__/cursor-public-install-sandbox.test.mjs scripts/__tests__/grok-public-install-sandbox.test.mjs scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs", + "test:release-registry": "node --test scripts/__tests__/cursor-public-install-sandbox.test.mjs scripts/__tests__/grok-public-install-sandbox.test.mjs scripts/__tests__/retain-runner-qualification-packages.test.mjs scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs", "storybook-visual:baseline": "node scripts/storybook-visual-baseline.mjs", "test:storybook-visual": "node scripts/storybook-visual-baseline.mjs download && node scripts/storybook-visual-baseline.mjs verify && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts", "test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack", diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index 9abfd933c9..b46b2476f1 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -538,6 +538,7 @@ export async function prepareCommandManagedRuntime(input: { adapterKey: string; workspaceLocalDir: string; workspaceRemoteDir?: string; + runtimeRootDir?: string; syncWorkspace?: boolean; workspaceInboundMode?: WorkspaceInboundMode; workspaceDurableSeed?: WorkspaceDurableSeedPaths; @@ -572,7 +573,7 @@ export async function prepareCommandManagedRuntime(input: { transport: "sandbox", provider: input.spec.providerKey ?? "sandbox", sandboxId: input.spec.leaseId ?? "managed", - remoteCwd: workspaceRemoteDir, + remoteCwd: input.spec.remoteCwd, timeoutMs, apiKey: null, }; @@ -605,6 +606,7 @@ export async function prepareCommandManagedRuntime(input: { adapterKey: input.adapterKey, workspaceLocalDir: input.workspaceLocalDir, workspaceRemoteDir, + runtimeRootDir: input.runtimeRootDir, syncWorkspace: input.syncWorkspace, workspaceInboundMode: input.workspaceInboundMode, workspaceDurableSeed: input.workspaceDurableSeed, @@ -649,6 +651,7 @@ export async function prepareCommandManagedRuntime(input: { adapterKey: input.adapterKey, workspaceLocalDir: input.workspaceLocalDir, workspaceRemoteDir, + runtimeRootDir: input.runtimeRootDir, syncWorkspace: input.syncWorkspace, workspaceInboundMode: input.workspaceInboundMode, workspaceDurableSeed: input.workspaceDurableSeed, diff --git a/packages/adapter-utils/src/directory-merge-lock.test.ts b/packages/adapter-utils/src/directory-merge-lock.test.ts index a2b71aac55..b10ad9189b 100644 --- a/packages/adapter-utils/src/directory-merge-lock.test.ts +++ b/packages/adapter-utils/src/directory-merge-lock.test.ts @@ -44,7 +44,9 @@ describe("directory merge lock process lifetime", () => { } async function holder(target: string, env: NodeJS.ProcessEnv) { - const child = spawn(process.execPath, ["--import", loader, "--eval", ` + // Import the loader into the holder itself. The tsx CLI starts another + // process, so killing that launcher need not retire the SQLite lock holder. + const child = spawn(process.execPath, ["--import", loader, "--input-type=module", "--eval", ` import { withDirectoryMergeLock } from ${JSON.stringify(module)}; withDirectoryMergeLock(${JSON.stringify(target)}, async () => { process.send?.("locked"); @@ -117,7 +119,7 @@ describe("directory merge lock process lifetime", () => { await expect(withDirectoryMergeLock(target, async () => undefined, env, undefined, TIMEOUT_ASSERTION_WAIT_MS)).rejects.toMatchObject({ code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE }); // A same-process test alone cannot prove that the OS lock survived: on // POSIX, closing an unmanaged descriptor can drop process-wide locks. - const result = await promisify(execFile)(process.execPath, ["--import", loader, "--eval", ` + const result = await promisify(execFile)(process.execPath, ["--import", loader, "--input-type=module", "--eval", ` import { withDirectoryMergeLock, WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from ${JSON.stringify(module)}; withDirectoryMergeLock(${JSON.stringify(target)}, async () => "entered", undefined, undefined, ${TIMEOUT_ASSERTION_WAIT_MS}) .then(() => { console.error("Entered a live holder's lock"); process.exit(1); }) diff --git a/packages/adapter-utils/src/execution-target.test.ts b/packages/adapter-utils/src/execution-target.test.ts index 5d89d97d3f..46a4df7d6d 100644 --- a/packages/adapter-utils/src/execution-target.test.ts +++ b/packages/adapter-utils/src/execution-target.test.ts @@ -431,7 +431,7 @@ describe("GitHub launcher lifecycle", () => { providerKey: "e2b", remoteCwd: "/remote/workspace", runner }; await cleanupGitHubOperationLaunchers({ runId: "finished-run", target }); expect(runner.execute).toHaveBeenCalledWith({ command: "sh", - args: ["-c", "rm -rf -- '/remote/workspace/.paperclip-runtime/github/finished-run'"], + args: ["-c", "rm -rf -- '/remote/workspace/.paperclip-runtime/paperclip-runner/github/finished-run'"], cwd: "/remote/workspace", timeoutMs: 5_000 }); await expect(cleanupGitHubOperationLaunchers({ runId: "../other", target })).rejects.toThrow("Invalid GitHub launcher run ID"); expect(runner.execute).toHaveBeenCalledTimes(1); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 42a05cbb89..c70c02d7b9 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -1428,6 +1428,8 @@ export async function prepareAdapterExecutionTargetRuntime(input: { workspaceLocalDir: string; timeoutSec?: number; workspaceRemoteDir?: string; + /** Sandbox transfer scratch, confined to the lease's reserved runtime tree. */ + runtimeRootDir?: string; syncWorkspace?: boolean; workspaceInboundMode?: WorkspaceInboundMode; workspaceDurableSeed?: WorkspaceDurableSeedPaths; @@ -1512,6 +1514,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: { adapterKey: input.adapterKey, workspaceLocalDir: input.workspaceLocalDir, workspaceRemoteDir: input.workspaceRemoteDir, + runtimeRootDir: input.runtimeRootDir, syncWorkspace: input.syncWorkspace, workspaceInboundMode: input.workspaceInboundMode, workspaceDurableSeed: input.workspaceDurableSeed, @@ -1553,11 +1556,13 @@ type GitHubLauncherLocation = { runId: string; target: AdapterExecutionTarget | null | undefined; }; -function githubOperationLauncherDirectory(input: GitHubLauncherLocation): string { +/** Keep sandbox housekeeping within the producer-owned Runner runtime. */ +export function githubOperationLauncherDirectory(input: GitHubLauncherLocation): string { // Only controller-generated run IDs may name a removable directory. if (!/^[a-zA-Z0-9_-]+$/.test(input.runId)) throw new Error("Invalid GitHub launcher run ID"); return input.target?.kind === "remote" - ? path.posix.join(input.target.remoteCwd, ".paperclip-runtime", "github", input.runId) + ? path.posix.join(input.target.remoteCwd, ".paperclip-runtime", + ...(input.target.transport === "sandbox" ? ["paperclip-runner"] : []), "github", input.runId) : path.join(os.tmpdir(), "paperclip-github-runtime", input.runId); } diff --git a/packages/adapter-utils/src/github-launcher-environment.test.ts b/packages/adapter-utils/src/github-launcher-environment.test.ts index 2dab7d4af0..7259d65451 100644 --- a/packages/adapter-utils/src/github-launcher-environment.test.ts +++ b/packages/adapter-utils/src/github-launcher-environment.test.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { watch } from "node:fs"; import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -67,6 +68,38 @@ async function sandbox(layout: string) { } describe("managed GitHub launcher environment", () => { + // The Daytona native directory oracle uses Linux inotify. Run its real + // filesystem regression on that platform; shell/launcher tests cover both. + it.runIf(process.platform === "linux")("keeps launcher restaging and command scratch inside the runner runtime", async () => { + const fixture = await sandbox("usr/bin"); + const privateRoot = path.join(fixture.root, ".paperclip-runtime"); + await mkdir(path.join(privateRoot, "paperclip-runner"), { recursive: true }); + await writeFile(path.join(fixture.root, "user.txt"), "Existing work\n"); + const mutations: string[] = []; + const watchers = [fixture.root, privateRoot].map((directory) => watch(directory, (_kind, name) => { + const changed = path.relative(fixture.root, path.join(directory, String(name))); + if (changed !== ".paperclip-runtime/paperclip-runner") mutations.push(changed); + })); + try { + const input = { runId: "restart-same-launchers", target: fixture.target, cwd: fixture.root, + env: githubBrokerEnvironment({}, { url: "", token: "" }) }; + const first = await prepareGitHubOperationLaunchers(input); + const original = await readFile(path.join(first.PAPERCLIP_GITHUB_LAUNCHER_DIR, "git"), "utf8"); + const restored = await prepareGitHubOperationLaunchers(input); + const command = await fixture.runner.execute({ + command: path.join(restored.PAPERCLIP_GITHUB_LAUNCHER_DIR, "git"), args: ["--version"], env: restored, + }); + expect(command.exitCode, command.stderr).toBe(0); + expect(await readFile(path.join(restored.PAPERCLIP_GITHUB_LAUNCHER_DIR, "git"), "utf8")).toBe(original); + expect(await readFile(path.join(fixture.root, "user.txt"), "utf8")).toBe("Existing work\n"); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(mutations).toEqual([]); + // The watcher must still notice housekeeping outside that exact runtime. + await mkdir(path.join(privateRoot, "outside-runtime-control")); + await vi.waitFor(() => expect(mutations).toContain(".paperclip-runtime/outside-runtime-control")); + } finally { for (const watcher of watchers) watcher.close(); } + }); + it.each(["module", "commonjs"])("runs managed GitHub launchers inside a %s project", async (type) => { const fixture = await sandbox("usr/bin"); const packageJson = JSON.stringify({ type }); diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 3240fdf478..44012753f7 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -127,6 +127,7 @@ export { normalizeLegacyRunnerProvider, resolvePaperclipRunnerPermissionMode, resolvePaperclipRunnerCursorMode, + resolvePaperclipRunnerPiThinkingLevel, } from "./paperclip-runner-permissions.js"; export { PAPERCLIP_RUNNER_INGRESS_PORT, diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.test.ts b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts index ac73cdc044..cc44ae4cdf 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.test.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts @@ -13,7 +13,7 @@ import { describe("Paperclip Runner permission defaults", () => { it("holds intermediate Pi admission and preserves upstream Cursor qualification", () => { expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "pi")) - .toMatchObject({ qualified: false, credentialEnvironment: ["OPENROUTER_API_KEY"] }); + .toMatchObject({ qualified: false, credentialEnvironment: ["OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"] }); expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "copilot")?.qualified).toBe(false); expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "cursor")?.qualified).toBe(true); }); diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.ts b/packages/adapter-utils/src/paperclip-runner-permissions.ts index 93976c8ebc..d2a4f92469 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.ts @@ -246,5 +246,17 @@ export const PAPERCLIP_RUNNER_ACPX_PROFILES = Object.freeze([ { value: "claude", label: "Claude", qualified: true, credentialEnvironment: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] }, { value: "cursor", label: "Cursor", qualified: true, credentialEnvironment: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"] }, { value: "copilot", label: "GitHub Copilot", qualified: false, credentialEnvironment: ["COPILOT_GITHUB_TOKEN"] }, - { value: "pi", label: "Pi", qualified: false, credentialEnvironment: ["OPENROUTER_API_KEY"] }, + { value: "pi", label: "Pi", qualified: false, credentialEnvironment: ["OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"] }, ] as const); + +/** Exact Pi native thinking levels; aliases that silently clamp are not configuration. */ +export type PaperclipRunnerPiThinkingLevel = "off" | "low" | "high" | "max"; +export function resolvePaperclipRunnerPiThinkingLevel(provider: unknown, agent: unknown, value: unknown): PaperclipRunnerPiThinkingLevel | undefined { + if (provider !== "acpx" || agent !== "pi") { + if (value !== undefined) throw new Error("piThinkingLevel is supported only for Pi"); + return undefined; + } + if (value === undefined) return "low"; + if (value === "off" || value === "low" || value === "high" || value === "max") return value; + throw new Error("Pi thinking level must be off, low, high, or max"); +} diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 9ea60e5147..c059b4535b 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -1097,6 +1097,9 @@ export async function prepareSandboxManagedRuntime(input: { client: SandboxManagedRuntimeClient; workspaceLocalDir: string; workspaceRemoteDir?: string; + /** Host-owned transfer scratch within the lease's reserved runtime tree. + * Persistent subdirectories can keep scratch outside their native file root. */ + runtimeRootDir?: string; syncWorkspace?: boolean; /** Selects authoritative host staging, exact durable-seed replay, or no-overwrite adoption. */ workspaceInboundMode?: WorkspaceInboundMode; @@ -1127,7 +1130,14 @@ export async function prepareSandboxManagedRuntime(input: { runtimeSpan?: RuntimeSpanRunner; }): Promise { const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd; - const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey); + const runtimeRootDir = input.runtimeRootDir ?? path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey); + if (input.runtimeRootDir !== undefined) { + const reservedRoot = path.posix.join(input.spec.remoteCwd, ".paperclip-runtime"); + if (!path.posix.isAbsolute(runtimeRootDir) || path.posix.normalize(runtimeRootDir) !== runtimeRootDir + || runtimeRootDir.includes("\0") || runtimeRootDir.endsWith("/") || !runtimeRootDir.startsWith(`${reservedRoot}/`)) { + throw new Error("Transfer scratch must remain within the lease runtime tree"); + } + } // A workspace directory that does not exist on this host has nothing to // stage, no files for ignore rules to govern, and nothing to restore into — // callers that only stage credential assets (the adapter env tests) hand diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts index 248f3d382e..82db065ae3 100644 --- a/packages/adapters/codex-local/src/ui/build-config.test.ts +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -231,7 +231,7 @@ describe("buildPaperclipRunnerConfig", () => { expect(config).not.toHaveProperty("acpxAgent"); }); - it.each(["pi", "copilot"])("rejects unavailable ACPX %s without selecting another provider", (acpxAgent) => { + it.each(["copilot"])("rejects unavailable ACPX %s without selecting another provider", (acpxAgent) => { expect(() => buildPaperclipRunnerConfig(makeValues({ adapterType: "paperclip_runner", model: "explicit-provider-model", @@ -349,3 +349,11 @@ describe("buildPaperclipRunnerConfig", () => { expect(config).not.toHaveProperty("idleTimeoutMs"); }); }); + +it.each([undefined, "off", "low", "high", "max"] as const)("builds exact Pi thinking configuration %s", piThinkingLevel => { + const config = buildPaperclipRunnerConfig(makeValues({ adapterType: "paperclip_runner", model: "openrouter/deepseek/deepseek-v4-flash-0731", adapterSchemaValues: { provider: "acpx", acpxAgent: "pi", piThinkingLevel } })); + expect(config.piThinkingLevel).toBe(piThinkingLevel ?? "low"); +}); +it("rejects silently clamped Pi aliases during configuration", () => { + expect(() => buildPaperclipRunnerConfig(makeValues({ adapterType: "paperclip_runner", adapterSchemaValues: { provider: "acpx", acpxAgent: "pi", piThinkingLevel: "medium" } }))).toThrow(); +}); diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index ac8f748731..c49b96f666 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -6,6 +6,7 @@ import { resolvePaperclipRunnerIdleTimeoutMs, resolvePaperclipRunnerPermissionMode, resolvePaperclipRunnerCursorMode, + resolvePaperclipRunnerPiThinkingLevel, PAPERCLIP_RUNNER_ACPX_PROFILES, type CreateConfigValues, } from "@paperclipai/adapter-utils"; @@ -97,16 +98,13 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record profile.value === schemaValues.acpxAgent); if (provider === "acpx" && selectedAcpxProfile && !selectedAcpxProfile.qualified) { throw new Error(`${selectedAcpxProfile.label} is not enabled for production`); } const acpxAgent = selectedAcpxProfile?.value ?? "claude"; const cursorMode = resolvePaperclipRunnerCursorMode(provider, acpxAgent, schemaValues.acpxSessionMode); + const piThinkingLevel = resolvePaperclipRunnerPiThinkingLevel(provider, acpxAgent, schemaValues.piThinkingLevel); const schemaModel = typeof schemaValues.model === "string" ? schemaValues.model.trim() @@ -114,7 +112,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record bool { matches!(operation_id, "paperclip_finish" | "paperclip_block") @@ -141,6 +141,8 @@ struct AcpxProviderDescriptor { permission_mode: AcpxPermissionMode, #[serde(default, skip_serializing_if = "Option::is_none")] mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pi_thinking_level: Option, permission_mode_pinned: bool, #[serde(default)] provider_policy: Option, @@ -202,7 +204,7 @@ fn registered_asset_suffix(context: &Value) -> Option { return None; } blocks.push(if copy.get("kind").and_then(Value::as_str) == Some("agent_files") { - format!("Your persistent agent directory (AGENT_HOME) is {path}. Your instruction entry is {entry}, relative to that directory. All supported files and subfolders there are restored across tasks and sessions, and collected after this provider stops. Write task deliverables in the task working directory. Only changed or deleted files synchronize; the last sync wins for the same file. Temporary copies are cleaned up without retaining file history. Check the save receipt before claiming persistence.") + format!("Your persistent agent directory (AGENT_HOME) is {path}. This is the current turn's copy; its absolute path may change between turns. In shell commands, use the current $AGENT_HOME environment variable instead of an absolute agent-directory path from an earlier turn. Your instruction entry is {entry}, relative to that directory. All supported files and subfolders there are restored across tasks and sessions, and collected after this provider stops. Write task deliverables in the task working directory. Only changed or deleted files synchronize; the last sync wins for the same file. Temporary copies are cleaned up without retaining file history. Check the save receipt before claiming persistence.") } else { format!("Your editable agent instruction file is {path}/{entry}. Edit this registered private copy normally. After this run stops, Paperclip saves changed content as a persistent revision if your responsible user still has permission and the baseline has not changed. Check the run's instruction-save receipt before claiming persistence. Conflicts are preserved for explicit resolution. Repository instruction files, skills, and this run's loaded prompt are separate and are not collected.") }); @@ -264,6 +266,7 @@ impl AcpxProviderDescriptor { || self.model.trim().is_empty() || self.model.len() > 240 || self.model.contains('\0') + || ((self.agent == "pi") != self.pi_thinking_level.is_some()) || self.mode.as_ref().is_some_and(|mode| { mode.trim().is_empty() || mode.chars().count() > 240 @@ -364,6 +367,7 @@ impl AcpxProviderDescriptor { working_directory: PathBuf::from(&self.cwd), permission_mode: self.permission_mode, mode: self.mode.clone(), + pi_thinking_level: self.pi_thinking_level, permission_mode_pinned: self.permission_mode_pinned, provider_policy: self.provider_policy.clone(), system_instructions: self.instructions.clone(), @@ -397,7 +401,11 @@ impl AcpxProviderDescriptor { "ACPX runner launch profile repeats an artifact path", )); } - let snapshot = verify_launch_artifact(artifact, "ACPX")?; + let snapshot = if artifact.path == launch_profile.command { + verify_executable_launch_artifact(artifact, "ACPX")? + } else { + verify_launch_artifact(artifact, "ACPX")? + }; verified.insert(artifact.path.clone(), snapshot); } let command = verified @@ -464,6 +472,9 @@ impl AcpxProviderDescriptor { "acpxRecordId": identity.map(|value| value.acpx_record_id.as_str()), "permissionMode": self.permission_mode, }); + if let Some(level) = self.pi_thinking_level { + descriptor["piThinkingLevel"] = json!(level); + } if let Some(mode) = self.mode.as_deref() { descriptor["mode"] = json!(mode); } @@ -599,6 +610,7 @@ impl AcpxDurableState { .map_err(|error| DurableRunnerError::invalid(error.to_string()))?; if identity.profile_digest != self.descriptor.command_digest || identity.mode != self.descriptor.mode + || identity.pi_thinking_level != self.descriptor.pi_thinking_level { return Err(DurableRunnerError::invalid( "ACPX durable identity no longer matches its qualified profile or provider mode", @@ -740,6 +752,33 @@ fn validate_pending_runtime_requests( Ok(()) } +fn attested_pending_runtime_requests( + pending: &BTreeMap, + provider: &crate::acpx_provider_state::AcpxProviderState, + live: &BTreeMap, + durable_turn_id: &str, +) -> Vec { + pending + .values() + .filter(|request| { + let id = request["requestId"].as_str().unwrap_or(""); + // Canonical requests bind the durable PRP turn. The sidecar + // separately attests the provider-assigned turn. + request["turnId"].as_str() == Some(durable_turn_id) + && if request["type"] == "input" { + provider + .pending_provider_input_request_id(id) + .and_then(|provider_id| live.get(provider_id)) + .is_some_and(|kind| kind == "input") + } else { + provider.pending_permission(id).is_some() + && live.get(id).is_some_and(|kind| kind == "permission") + } + }) + .cloned() + .collect() +} + pub struct AcpxCommandExecutor { state_dir: PathBuf, context: AcpxEventProjectionContext, @@ -753,6 +792,28 @@ pub struct AcpxCommandExecutor { launch_profile: Option, } +// The durable command's turn and the live provider callback have separate IDs. +// Legacy direct callers use turnId for both; an explicit provider binding must +// never fall back to the durable ID if it is malformed. +fn turn_control_provider_turn_id(payload: &Value) -> Result<&str, DurableRunnerError> { + let durable_turn_id = payload + .get("turnId") + .and_then(Value::as_str) + .ok_or_else(|| DurableRunnerError::invalid("turn.steer payload.turnId is required"))?; + let provider_turn_id = match payload.get("providerTurnId") { + None => durable_turn_id, + Some(value) => value.as_str().ok_or_else(|| { + DurableRunnerError::invalid("turn.steer payload.providerTurnId must be a string") + })?, + }; + if !is_stable_id(provider_turn_id, DURABLE_STABLE_ID_CHARS) { + return Err(DurableRunnerError::invalid( + "turn.steer provider turn identity is invalid", + )); + } + Ok(provider_turn_id) +} + impl AcpxCommandExecutor { pub fn with_runner_config(state_dir: impl Into, config: &DurableRunnerConfig) -> Self { Self { @@ -1225,16 +1286,21 @@ impl AcpxCommandExecutor { session_event_payload(&state.descriptor, &identity, process_id, turn_controls); let goal = self.goal_control("session.goal.get", &json!({}))?; self.save_state()?; + let mut result = json!({ + "status": if resumed { "resumed" } else { "started" }, + "provider": "acpx", + "driver": "acpx_runtime", + "providerVersion": "0.13.1", + "providerSessionId": identity.acpx_record_id, + "sessionId": identity.agent_session_id, + "processId": process_id, + }); + if let Some(level) = identity.pi_thinking_level { + // This identity is admitted only after the sidecar's effective-mode ACK. + result["piThinkingLevel"] = json!(level); + } Ok(CommandExecution { - result: json!({ - "status": if resumed { "resumed" } else { "started" }, - "provider": "acpx", - "driver": "acpx_runtime", - "providerVersion": "0.13.1", - "providerSessionId": identity.acpx_record_id, - "sessionId": identity.agent_session_id, - "processId": process_id, - }), + result, events: [( if resumed { "session.resumed" @@ -1477,10 +1543,7 @@ impl AcpxCommandExecutor { .get("text") .and_then(Value::as_str) .ok_or_else(|| DurableRunnerError::invalid("turn.steer payload.text is required"))?; - let turn_id = payload - .get("turnId") - .and_then(Value::as_str) - .ok_or_else(|| DurableRunnerError::invalid("turn.steer payload.turnId is required"))?; + let turn_id = turn_control_provider_turn_id(payload)?; let mode = match payload.get("mode") { None => "steer", Some(Value::String(mode)) => mode.as_str(), @@ -1586,25 +1649,33 @@ impl AcpxCommandExecutor { .state .as_ref() .and_then(|state| state.active_turn_id.clone()); - let Some(turn_id) = turn_id else { + let stop_idle_pi = turn_id.is_none() + && self.session.is_some() + && self + .state + .as_ref() + .is_some_and(|state| state.descriptor.agent == "pi"); + if turn_id.is_none() && !stop_idle_pi { return Ok(CommandExecution::result(json!({ "status": "already_settled", "reason": reason, }))); - }; + } let provider_lifetime_fence_candidates = { let session = self .session .as_mut() .ok_or_else(|| DurableRunnerError::invalid("ACPX session is unavailable"))?; let candidates = session.identity().provider_lifetime_fence_candidates; - session - .terminate_active_turn_for_suspension(&turn_id) - .map_err(|error| { - DurableRunnerError::invalid(format!( - "failed to terminate ACPX turn at the suspension boundary: {error}" - )) - })?; + match turn_id.as_deref() { + Some(turn_id) => session.terminate_active_turn_for_suspension(turn_id), + None => session.terminate_idle_for_suspension(), + } + .map_err(|error| { + DurableRunnerError::invalid(format!( + "failed to terminate ACPX provider at the suspension boundary: {error}" + )) + })?; candidates }; // Process-group termination reaps the sidecar leader and its ordinary @@ -1776,6 +1847,40 @@ impl AcpxCommandExecutor { }))) } + fn snapshot_live_requests(&mut self) -> Result { + let session = self.session.as_mut().ok_or_else(|| { + DurableRunnerError::invalid("ACPX request snapshot requires the surviving provider") + })?; + let live_requests = session.verify_live_request_snapshot().map_err(|error| { + DurableRunnerError::invalid(format!("ACPX live request snapshot failed: {error}")) + })?; + let state = self + .state + .as_ref() + .ok_or_else(|| DurableRunnerError::invalid("ACPX provider state is unavailable"))?; + if state.provider_exit_unconfirmed + || state.lifecycle == "closed" + || state.identity.as_ref() != Some(session.identity()) + || state.active_turn_id.as_deref() != session.state().active_turn_id() + { + return Err(DurableRunnerError::invalid( + "ACPX live request snapshot lost its provider binding", + )); + } + validate_pending_runtime_requests(&state.pending_runtime_requests)?; + let requests = attested_pending_runtime_requests( + &state.pending_runtime_requests, + session.state(), + &live_requests, + &self.context.turn_id, + ); + let mut snapshot = self.snapshot()?; + snapshot.result["pendingRuntimeRequests"] = json!(requests); + snapshot.result["runtimeRequestsLive"] = json!(true); + snapshot.result["runtimeRequestTurnId"] = json!(self.context.turn_id); + Ok(snapshot) + } + fn close_session(&mut self, reason: &str) -> Result { if let Some(session) = self.session.as_mut() { session.shutdown(reason).map_err(|error| { @@ -1807,6 +1912,15 @@ impl AcpxCommandExecutor { } fn suspend(&mut self) -> Result { + if self + .state + .as_ref() + .is_some_and(|state| state.provider_exit_unconfirmed) + { + return Err(DurableRunnerError::invalid( + "ACPX provider lifetime cleanup is not yet proven", + )); + } if let Some(session) = self.session.as_mut() { let identity = session.suspend("runner.suspend").map_err(|error| { DurableRunnerError::invalid(format!("failed to suspend ACPX provider: {error}")) @@ -2055,6 +2169,15 @@ impl CommandExecutor for AcpxCommandExecutor { "turn.stop" => self.stop_turn_for_suspension(&command.command_type), "request.resolve" => self.resolve_request(&command.payload), "semantic_tool.result" => self.deliver_tool_result(&command.payload), + "session.snapshot" + if command + .payload + .get("includePendingRuntimeRequests") + .and_then(Value::as_bool) + == Some(true) => + { + self.snapshot_live_requests() + } "session.snapshot" => self.snapshot(), "session.close" | "session.destroy" => self.close_session(&command.command_type), "runner.suspend" => self.suspend(), @@ -2379,6 +2502,9 @@ mod tests { if agent == "cursor" { value["mode"] = json!("agent"); } + if agent == "pi" { + value["piThinkingLevel"] = json!("low"); + } value } @@ -2388,6 +2514,84 @@ mod tests { "origin":{"adapter":"acpx-runtime-sidecar","provider":"cursor","method":"cursor/ask_question"}}) } + #[test] + fn live_request_snapshot_preserves_durable_turn_and_provider_callback_bindings() { + use crate::acpx_provider_state::AcpxProviderState; + use crate::acpx_sidecar_transport::AcpxSidecarEvent; + use crate::generated_acpx_sidecar_contract::GeneratedAcpxSidecarEventType; + let mut provider = AcpxProviderState::new("run-1").unwrap(); + provider.begin_turn("provider-turn-1").unwrap(); + let mut projection = context(); + projection.turn_id = "durable-turn-1".into(); + projection.provider_turn_id = Some("provider-turn-1".into()); + let mut pending = BTreeMap::new(); + for (sequence, event_type, payload) in [ + ( + 1, + GeneratedAcpxSidecarEventType::RuntimeInputRequested, + json!({"requestId":"raw input / 1","questionSet":pending_input_request("unused")["input"]}), + ), + ( + 2, + GeneratedAcpxSidecarEventType::RuntimePermissionRequested, + json!({"requestId":"permission-1","kind":"execute","title":"Run?","choices":[{"key":"decline","label":"Decline"}]}), + ), + ] { + let event = AcpxSidecarEvent { + sequence, + event_type, + run_id: Some("run-1".into()), + turn_id: Some("provider-turn-1".into()), + payload, + }; + for event in provider.accept_event(&event).unwrap() { + for normalized in project_acpx_state_event(&projection, &event).unwrap() { + if normalized.event_type == "runtime_request.created" { + let request = normalized.payload["request"].clone(); + pending.insert(request["requestId"].as_str().unwrap().to_owned(), request); + } + } + } + } + validate_pending_runtime_requests(&pending).unwrap(); + let live = BTreeMap::from([ + ("raw input / 1".into(), "input".into()), + ("permission-1".into(), "permission".into()), + ]); + let requests = + attested_pending_runtime_requests(&pending, &provider, &live, "durable-turn-1"); + assert_eq!(requests.len(), 2); + assert!(requests + .iter() + .all(|request| request["turnId"] == "durable-turn-1")); + assert!(requests + .iter() + .any(|request| request["requestId"] != "raw input / 1" && request["type"] == "input")); + assert!( + attested_pending_runtime_requests(&pending, &provider, &live, "provider-turn-1") + .is_empty() + ); + assert!(attested_pending_runtime_requests( + &pending, + &provider, + &BTreeMap::new(), + "durable-turn-1" + ) + .is_empty()); + provider.complete_permission("permission-1").unwrap(); + let input_id = requests + .iter() + .find(|request| request["type"] == "input") + .unwrap()["requestId"] + .as_str() + .unwrap(); + provider.complete_input(input_id).unwrap(); + assert!( + attested_pending_runtime_requests(&pending, &provider, &live, "durable-turn-1") + .is_empty() + ); + } + #[test] fn durable_runtime_ledger_retains_only_unsettled_requests_and_rejects_forged_types() { let operations = Vec::new(); @@ -2549,6 +2753,7 @@ mod tests { effective_model: descriptor.model.clone(), permission_mode: Some(descriptor.permission_mode), mode: descriptor.mode.clone(), + pi_thinking_level: descriptor.pi_thinking_level, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; let operations = Vec::new(); @@ -2689,6 +2894,31 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } + #[test] + fn turn_control_uses_the_explicit_live_provider_binding() { + let command = json!({"turnId":"durable-turn", "providerTurnId":"provider-turn"}); + assert_eq!( + turn_control_provider_turn_id(&command).unwrap(), + "provider-turn" + ); + let legacy = json!({"turnId":"provider-turn"}); + assert_eq!( + turn_control_provider_turn_id(&legacy).unwrap(), + "provider-turn" + ); + for malformed in [ + Value::Null, + json!(false), + json!(1), + json!(""), + json!("bad\0id"), + ] { + let command = json!({"turnId":"provider-turn", "providerTurnId":malformed}); + assert!(turn_control_provider_turn_id(&command).is_err()); + } + assert!(turn_control_provider_turn_id(&json!({"providerTurnId":"provider-turn"})).is_err()); + } + #[test] fn retained_events_exposes_terminal_suffix_without_restoring_provider() { let directory = temporary_directory("retained-terminal-suffix"); @@ -2799,6 +3029,7 @@ mod tests { effective_model: "gpt-5.6-sol".to_owned(), permission_mode: Some(AcpxPermissionMode::ApproveReads), mode: None, + pi_thinking_level: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; @@ -2950,6 +3181,38 @@ mod tests { } } + #[test] + fn pi_profile_matches_published_identity_and_rejects_prior_profiles() { + let published: Value = serde_json::from_str(include_str!( + "../../../../test-fixtures/pi-acp/profile-v20-identity.json" + )) + .unwrap(); + let mut value = descriptor("codex"); + value["agent"] = json!("pi"); + value["model"] = json!("openrouter/deepseek/deepseek-v4-flash-0731"); + value["agentServerPackage"] = json!("pi-acp"); + value["agentServerVersion"] = json!("0.0.33"); + value["agentRuntimePackage"] = json!("@earendil-works/pi-coding-agent"); + value["agentRuntimeVersion"] = json!("1.0.0"); + value["piThinkingLevel"] = json!("low"); + value["providerPolicy"] = json!({"readOnly": true}); + value["commandDigest"] = published["commandDigest"].clone(); + let current: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap(); + current.validate(&context()).unwrap(); + + for prior in [ + include_str!("../../../../test-fixtures/pi-acp/profile-v15-identity.json"), + include_str!("../../../../test-fixtures/pi-acp/profile-v16-identity.json"), + include_str!("../../../../test-fixtures/pi-acp/profile-v17-identity.json"), + include_str!("../../../../test-fixtures/pi-acp/profile-v18-identity.json"), + ] { + let prior: Value = serde_json::from_str(prior).unwrap(); + value["commandDigest"] = prior["commandDigest"].clone(); + let rejected: AcpxProviderDescriptor = serde_json::from_value(value.clone()).unwrap(); + assert!(rejected.validate(&context()).is_err()); + } + } + #[test] fn rejects_pi_with_another_profile_identity_before_process_launch() { let mut pi = descriptor("codex"); @@ -2997,6 +3260,135 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } + #[cfg(target_os = "macos")] + mod darwin_named_transport_cleanup { + use super::*; + + struct Fixture { + directory: PathBuf, + descriptor: AcpxProviderDescriptor, + profile: AcpxLaunchProfile, + } + + impl Fixture { + fn new(label: &str) -> Self { + let directory = temporary_directory(label); + let command = directory.join("node"); + let sidecar = directory.join("sidecar.cjs"); + write_artifact(&command, b"qualified node", true); + write_artifact(&sidecar, b"qualified sidecar", false); + let args = vec![sidecar.to_string_lossy().into_owned()]; + let profile = AcpxLaunchProfile { + authority_digest: format!("sha256:{}", "d".repeat(64)), + command: command.clone(), + args: args.clone(), + artifacts: vec![artifact(&command), artifact(&sidecar)], + }; + let mut value = descriptor("codex"); + value["sidecarCommand"] = json!(command); + value["sidecarArgs"] = json!(args); + Self { + directory, + descriptor: serde_json::from_value(value).unwrap(), + profile, + } + } + + fn named_images(&self) -> usize { + fs::read_dir(&self.directory) + .unwrap() + .map(|entry| entry.unwrap()) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".paperclip-verified-executable-") + }) + .count() + } + + fn prove_transport_owns_image(&self) { + assert_eq!(self.named_images(), 0); + let transport = self + .descriptor + .verified_transport(Some(&self.profile)) + .unwrap(); + assert_eq!( + self.named_images(), + 1, + "verification must own one named Node image" + ); + drop(transport); + assert_eq!( + self.named_images(), + 0, + "dropping unstarted transport must retire its image" + ); + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.directory); + } + } + + // File verification only: these tests never start a transport/process. + #[test] + fn second_artifact_failure_retires_named_executable() { + let fixture = Fixture::new("darwin-second-artifact-cleanup"); + fixture.prove_transport_owns_image(); + // The command remains valid and first in the profile; the sidecar + // fails only after the command's named snapshot has been admitted. + write_artifact( + &fixture.profile.artifacts[1].path, + b"tampered sidecar", + false, + ); + let error = fixture + .descriptor + .verified_transport(Some(&fixture.profile)) + .err() + .unwrap(); + assert!(error + .to_string() + .contains("verified process artifact digest mismatch")); + assert_eq!(fixture.named_images(), 0); + assert_eq!( + fs::read(&fixture.profile.command).unwrap(), + b"qualified node" + ); + } + + #[test] + fn argv_construction_failure_retires_named_executable() { + let mut fixture = Fixture::new("darwin-argv-cleanup"); + fixture.prove_transport_owns_image(); + // Descriptor and profile agree, so this passes launch binding and + // artifact verification, then fails the absolute-argument mapping. + let unauthenticated = fixture + .directory + .join("unauthenticated.cjs") + .to_string_lossy() + .into_owned(); + fixture.profile.args.push(unauthenticated.clone()); + fixture.descriptor.sidecar_args.push(unauthenticated); + let error = fixture + .descriptor + .verified_transport(Some(&fixture.profile)) + .err() + .unwrap(); + assert!(error + .to_string() + .contains("does not authenticate an absolute argument")); + assert_eq!(fixture.named_images(), 0); + assert_eq!( + fs::read(&fixture.profile.command).unwrap(), + b"qualified node" + ); + } + } + #[cfg(unix)] #[test] fn rejects_symlinked_launch_artifacts() { @@ -3071,7 +3463,7 @@ mod tests { args: Vec::new(), artifacts: vec![artifact(&command)], }; - let mut descriptor_value = descriptor("codex"); + let mut descriptor_value = descriptor("pi"); descriptor_value["sidecarCommand"] = json!(command); descriptor_value["runtimeContext"] = json!({ "instructions": { "digest": "stable" }, "mcp": { "digest": "before" }, "aggregateDigest": "before" }); descriptor_value["sidecarArgs"] = json!([]); @@ -3111,6 +3503,7 @@ mod tests { effective_model: original_descriptor.model.clone(), permission_mode: Some(original_descriptor.permission_mode), mode: original_descriptor.mode.clone(), + pi_thinking_level: original_descriptor.pi_thinking_level, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; let operations = Vec::new(); @@ -3263,7 +3656,7 @@ mod tests { .attach_run(&json!({"provider": same_run_mutation})) .unwrap_err() .to_string() - .contains("same_run_grant_changed")); + .contains("changed runtime context outside a new authenticated run")); // In-place warm handoff executes under the old authority. Only the // authenticated next-authority boundary may admit the new descriptor; @@ -3329,7 +3722,7 @@ mod tests { #[cfg(unix)] fn authenticated_run_grant_attachment(agent: &str) { - let directory = temporary_directory("cursor-cross-run-attach"); + let directory = temporary_directory(&format!("{agent}-cross-run-attach")); let runtime = directory.join("runtime"); let workspace = directory.join("workspace"); fs::create_dir_all(&runtime).unwrap(); @@ -3395,6 +3788,7 @@ mod tests { effective_model: original_descriptor.model.clone(), permission_mode: Some(original_descriptor.permission_mode), mode: original_descriptor.mode.clone(), + pi_thinking_level: original_descriptor.pi_thinking_level, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; let operations = Vec::new(); @@ -3688,6 +4082,7 @@ mod tests { effective_model: descriptor.model.clone(), permission_mode: Some(descriptor.permission_mode), mode: descriptor.mode.clone(), + pi_thinking_level: descriptor.pi_thinking_level, provider_lifetime_fence_candidates, }; let operations = Vec::new(); @@ -3860,6 +4255,7 @@ mod tests { effective_model: provider_descriptor.model.clone(), permission_mode: Some(provider_descriptor.permission_mode), mode: provider_descriptor.mode.clone(), + pi_thinking_level: provider_descriptor.pi_thinking_level, provider_lifetime_fence_candidates, }); state.provider_exit_unconfirmed = true; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_capabilities.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_capabilities.rs index 830e31b1fe..d9ebde3208 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_capabilities.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_capabilities.rs @@ -11,7 +11,7 @@ pub(crate) enum RunAttachmentPolicy { pub(crate) fn run_attachment_policy(agent: &str) -> RunAttachmentPolicy { match agent { - "cursor" => RunAttachmentPolicy::AuthenticatedRunGrants, + "cursor" | "pi" => RunAttachmentPolicy::AuthenticatedRunGrants, "claude" => RunAttachmentPolicy::AuthenticatedAssetPaths, _ => RunAttachmentPolicy::ImmutableInstructions, } @@ -27,11 +27,15 @@ mod tests { run_attachment_policy("cursor"), RunAttachmentPolicy::AuthenticatedRunGrants ); + assert_eq!( + run_attachment_policy("pi"), + RunAttachmentPolicy::AuthenticatedRunGrants + ); assert_eq!( run_attachment_policy("claude"), RunAttachmentPolicy::AuthenticatedAssetPaths ); - for agent in ["codex", "grok", "pi", "copilot", "unknown"] { + for agent in ["codex", "grok", "copilot", "unknown"] { assert_eq!( run_attachment_policy(agent), RunAttachmentPolicy::ImmutableInstructions 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 e3b4877116..48c79292cd 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 @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; use crate::acpx_provider_session::{ AcpxPermissionMode, AcpxProviderSessionConfig, AcpxProviderSessionIdentity, CursorMode, + PiThinkingLevel, }; use crate::durable::{ create_private_temporary_file, open_private_regular_file, verify_private_directory, @@ -50,6 +51,8 @@ struct PersistedAcpxProviderSessionIdentity { permission_mode: AcpxPermissionMode, #[serde(default, skip_serializing_if = "Option::is_none")] mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pi_thinking_level: Option, provider_lifetime_fence_candidates: [u16; 3], } @@ -73,6 +76,7 @@ impl PersistedAcpxProviderSessionIdentity { effective_model: identity.effective_model, permission_mode, mode: identity.mode, + pi_thinking_level: identity.pi_thinking_level, provider_lifetime_fence_candidates: identity.provider_lifetime_fence_candidates, }) } @@ -90,6 +94,7 @@ impl PersistedAcpxProviderSessionIdentity { effective_model: self.effective_model.clone(), permission_mode: Some(self.permission_mode), mode: self.mode.clone(), + pi_thinking_level: self.pi_thinking_level, provider_lifetime_fence_candidates: self.provider_lifetime_fence_candidates, } } @@ -111,6 +116,7 @@ impl AcpxSuspensionCheckpoint { || identity.effective_model != config.model || identity.permission_mode != Some(config.permission_mode) || identity.mode != config.mode + || identity.pi_thinking_level != config.pi_thinking_level || 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 c37e454202..85da2d348b 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 @@ -44,6 +44,16 @@ pub enum CursorMode { Ask, } +/// Exact native Pi modes. No aliases, clamping, or implicit Rust default. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum PiThinkingLevel { + Off, + Low, + High, + Max, +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AcpxProviderSessionIdentity { @@ -60,6 +70,8 @@ pub struct AcpxProviderSessionIdentity { pub permission_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pi_thinking_level: Option, pub provider_lifetime_fence_candidates: [u16; 3], } @@ -81,6 +93,7 @@ pub struct AcpxProviderSessionConfig { pub working_directory: PathBuf, pub permission_mode: AcpxPermissionMode, pub mode: Option, + pub pi_thinking_level: Option, pub permission_mode_pinned: bool, pub provider_policy: Option, pub system_instructions: String, @@ -96,6 +109,11 @@ impl AcpxProviderSessionConfig { LocalRunnerError::invalid("ACPX agent must name a known immutable profile") })?; validate_text(&self.model, MAX_MODEL_CHARS, "ACPX model")?; + if (self.agent == "pi") != self.pi_thinking_level.is_some() { + return Err(LocalRunnerError::invalid( + "ACPX Pi thinking level must be explicit for Pi and absent for other agents", + )); + } if let Some(mode) = self.mode.as_deref() { validate_text(mode, MAX_ID_CHARS, "ACPX provider mode")?; } @@ -169,6 +187,7 @@ impl AcpxProviderSessionConfig { || expected_identity.effective_model != self.model || expected_identity.permission_mode != Some(self.permission_mode) || expected_identity.mode != self.mode + || expected_identity.pi_thinking_level != self.pi_thinking_level { return Err(LocalRunnerError::invalid( "ACPX expected identity conflicts with the requested session", @@ -323,6 +342,35 @@ impl AcpxProviderSession { &self.state } + /// Read the same live sidecar before exposing its in-memory pending ledger. + /// A durable file alone cannot authorize a request after process loss. + pub fn verify_live_request_snapshot( + &mut self, + ) -> Result, LocalRunnerError> { + self.ensure_open()?; + if self.runtime_retired || self.transport_terminated { + return Err(LocalRunnerError::invalid( + "ACPX request snapshot requires a live provider", + )); + } + let snapshot = self + .transport + .request(GeneratedAcpxSidecarCommand::SessionSnapshot, json!({}))?; + let identity: AcpxProviderSessionIdentity = + serde_json::from_value(snapshot["identity"].clone()).map_err(|_| { + LocalRunnerError::invalid("ACPX request snapshot identity is invalid") + })?; + if identity != self.identity + || snapshot["runId"].as_str() != Some(self.config.run_id.as_str()) + || snapshot["turnId"].as_str() != self.state.active_turn_id() + { + return Err(LocalRunnerError::invalid( + "ACPX request snapshot changed session or turn", + )); + } + live_snapshot_requests(&snapshot, self.state.active_turn_id()) + } + pub fn catalog_revision(&self) -> u64 { self.catalog_revision } @@ -955,6 +1003,20 @@ impl AcpxProviderSession { self.terminate_transport() } + /// Retires an idle provider at the same owned-process boundary as an active + /// turn. The caller must prove the inherited lifetime fence before making + /// the persisted identity attachable; an RPC close cannot supply that proof. + pub fn terminate_idle_for_suspension(&mut self) -> Result<(), LocalRunnerError> { + self.ensure_open()?; + if self.state.active_turn_id().is_some() || self.state.has_pending_requests() { + return Err(LocalRunnerError::invalid( + "ACPX idle suspension requires a settled turn and no pending requests", + )); + } + self.closed = true; + self.terminate_transport() + } + fn terminate_transport(&mut self) -> Result<(), LocalRunnerError> { if self.transport_terminated { return Ok(()); @@ -1251,6 +1313,9 @@ fn session_open_params(config: &AcpxProviderSessionConfig, sidecar_tools: &[Valu "tools": &sidecar_tools, "expectedIdentity": config.expected_identity, }); + if let Some(level) = config.pi_thinking_level { + params["piThinkingLevel"] = json!(level); + } if let Some(mode) = config.mode.as_deref() { params["mode"] = json!(mode); } @@ -1364,6 +1429,7 @@ fn verify_open_response( || identity.effective_model != config.model || identity.permission_mode != Some(config.permission_mode) || identity.mode != config.mode + || identity.pi_thinking_level != config.pi_thinking_level || config .expected_identity .as_ref() @@ -1403,6 +1469,45 @@ fn verify_suspend_response( Ok(()) } +fn live_snapshot_requests( + snapshot: &Value, + active_turn_id: Option<&str>, +) -> Result, LocalRunnerError> { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct LiveRequest { + request_id: String, + r#type: String, + turn_id: String, + } + let requests = snapshot["pendingRuntimeRequests"] + .as_array() + .ok_or_else(|| { + LocalRunnerError::invalid("ACPX live request snapshot omitted its pending callbacks") + })?; + if requests.len() > 1_024 { + return Err(LocalRunnerError::invalid( + "ACPX live request snapshot exceeds its request bound", + )); + } + let mut live = std::collections::BTreeMap::new(); + for value in requests { + let request: LiveRequest = serde_json::from_value(value.clone()).map_err(|_| { + LocalRunnerError::invalid("ACPX live request snapshot callback is invalid") + })?; + validate_text(&request.request_id, MAX_ID_CHARS, "live callback id")?; + if !matches!(request.r#type.as_str(), "input" | "permission") + || Some(request.turn_id.as_str()) != active_turn_id + || live.insert(request.request_id, request.r#type).is_some() + { + return Err(LocalRunnerError::invalid( + "ACPX live request snapshot callback binding is invalid", + )); + } + } + Ok(live) +} + fn validate_text(value: &str, max_chars: usize, label: &str) -> Result<(), LocalRunnerError> { if value.trim().is_empty() || value.chars().count() > max_chars @@ -1494,6 +1599,45 @@ fn with_cleanup_error( #[cfg(test)] mod tests { + #[test] + fn live_request_snapshot_requires_current_unique_typed_callbacks() { + use super::*; + let request = json!({"requestId":"input-1","type":"input","turnId":"turn-1"}); + let snapshot = json!({"pendingRuntimeRequests":[request.clone(), + {"requestId":"permission-1","type":"permission","turnId":"turn-1"}]}); + let live = live_snapshot_requests(&snapshot, Some("turn-1")).unwrap(); + assert_eq!(live.get("input-1").map(String::as_str), Some("input")); + assert_eq!( + live.get("permission-1").map(String::as_str), + Some("permission") + ); + for invalid in [ + json!({}), + json!({"pendingRuntimeRequests":null}), + json!({"pendingRuntimeRequests":[request.clone(),request.clone()]}), + json!({"pendingRuntimeRequests":[{"requestId":"","type":"input","turnId":"turn-1"}]}), + json!({"pendingRuntimeRequests":[{"requestId":"input-1","type":"tool","turnId":"turn-1"}]}), + json!({"pendingRuntimeRequests":[{"requestId":"input-1","type":"input","turnId":"turn-2"}]}), + json!({"pendingRuntimeRequests":[{"requestId":"input-1","type":"input","turnId":"turn-1","extra":true}]}), + ] { + assert!( + live_snapshot_requests(&invalid, Some("turn-1")).is_err(), + "{invalid}" + ); + } + assert!(live_snapshot_requests(&snapshot, None).is_err()); + assert!(live_snapshot_requests( + &json!({"pendingRuntimeRequests":vec![request;1_025]}), + Some("turn-1") + ) + .is_err()); + assert!( + live_snapshot_requests(&json!({"pendingRuntimeRequests":[]}), None) + .unwrap() + .is_empty() + ); + } + #[test] fn turn_controls_require_exact_live_pi_capability_fields() { use super::*; @@ -1610,6 +1754,7 @@ mod mode_tests { working_directory: std::env::temp_dir(), permission_mode: AcpxPermissionMode::ApproveReads, mode: Some("plan".to_owned()), + pi_thinking_level: None, permission_mode_pinned: true, provider_policy: Some(AcpxProviderRuntimePolicy { read_only: false }), system_instructions: String::new(), @@ -1636,6 +1781,7 @@ mod mode_tests { effective_model: "explicit-model".to_owned(), permission_mode: Some(AcpxPermissionMode::ApproveReads), mode: Some("plan".to_owned()), + pi_thinking_level: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], } } @@ -1644,6 +1790,11 @@ mod mode_tests { let mut config = config(); for agent in ["claude", "codex", "pi", "grok", "cursor", "copilot"] { config.agent = agent.to_owned(); + config.pi_thinking_level = if agent == "pi" { + Some(PiThinkingLevel::Low) + } else { + None + }; config.model = "custom/model[context=272k,reasoning=medium]".to_owned(); config.validate().unwrap(); assert_eq!( @@ -1751,4 +1902,137 @@ mod mode_tests { ) .is_err()); } + + fn pi_config() -> AcpxProviderSessionConfig { + let mut config = config(); + config.agent = "pi".to_owned(); + config.model = "openrouter/deepseek/deepseek-v4-flash-0731".to_owned(); + config.mode = None; + config.pi_thinking_level = Some(PiThinkingLevel::Low); + config + } + + fn pi_identity(config: &AcpxProviderSessionConfig) -> AcpxProviderSessionIdentity { + let mut identity = identity(); + identity.requested_model = config.model.clone(); + identity.effective_model = config.model.clone(); + identity.mode = None; + identity.pi_thinking_level = config.pi_thinking_level; + identity + } + + #[test] + fn pi_thinking_level_is_closed_explicit_and_pi_only() { + for (name, level) in [ + ("off", PiThinkingLevel::Off), + ("low", PiThinkingLevel::Low), + ("high", PiThinkingLevel::High), + ("max", PiThinkingLevel::Max), + ] { + assert_eq!( + serde_json::from_value::(json!(name)).unwrap(), + level + ); + assert_eq!(serde_json::to_value(level).unwrap(), json!(name)); + } + for value in [ + json!("medium"), + json!("minimal"), + json!("xhigh"), + json!("Low"), + json!(" low"), + json!(null), + json!(1), + ] { + assert!(serde_json::from_value::(value).is_err()); + } + let mut config = pi_config(); + config.validate().unwrap(); + config.pi_thinking_level = None; + assert!(config.validate().is_err()); + config = self::config(); + config.pi_thinking_level = Some(PiThinkingLevel::Low); + assert!(config.validate().is_err()); + config.pi_thinking_level = None; + config.validate().unwrap(); + } + + #[test] + fn pi_thinking_open_requires_exact_effective_ack_for_every_level() { + let mut config = pi_config(); + for level in [ + PiThinkingLevel::Off, + PiThinkingLevel::Low, + PiThinkingLevel::High, + PiThinkingLevel::Max, + ] { + config.pi_thinking_level = Some(level); + assert_eq!( + session_open_params(&config, &[])["piThinkingLevel"], + json!(level) + ); + let identity = pi_identity(&config); + let response = json!({"sidecarPid": 100, "status": {}, "identity": identity}); + assert_eq!( + verify_open_response(&response, 100, &config).unwrap(), + identity + ); + for wrong in [ + None, + Some(PiThinkingLevel::Off), + Some(PiThinkingLevel::Low), + Some(PiThinkingLevel::High), + Some(PiThinkingLevel::Max), + ] { + if wrong == Some(level) { + continue; + } + let mut changed = response.clone(); + changed["identity"]["piThinkingLevel"] = json!(wrong); + assert!(verify_open_response(&changed, 100, &config).is_err()); + } + let mut missing = response.clone(); + missing["identity"] + .as_object_mut() + .unwrap() + .remove("piThinkingLevel"); + assert!(verify_open_response(&missing, 100, &config).is_err()); + let mut alias = response.clone(); + alias["identity"]["piThinkingLevel"] = json!("medium"); + assert!(verify_open_response(&alias, 100, &config).is_err()); + } + let non_pi = self::config(); + assert!(session_open_params(&non_pi, &[]) + .get("piThinkingLevel") + .is_none()); + let mut foreign = identity(); + foreign.pi_thinking_level = Some(PiThinkingLevel::Low); + assert!(verify_open_response( + &json!({"sidecarPid":100,"status":{},"identity":foreign}), + 100, + &non_pi + ) + .is_err()); + } + + #[test] + fn pi_thinking_reopen_and_suspend_reject_missing_or_changed_mode() { + let mut config = pi_config(); + let identity = pi_identity(&config); + config.expected_identity = Some(identity.clone()); + config.validate().unwrap(); + for level in [None, Some(PiThinkingLevel::High)] { + let mut old = identity.clone(); + old.pi_thinking_level = level; + config.expected_identity = Some(old.clone()); + assert!(config.validate().is_err()); + assert!( + verify_suspend_response(&json!({"suspended":true,"identity":old}), &identity) + .is_err() + ); + } + config.expected_identity = Some(identity.clone()); + config.pi_thinking_level = Some(PiThinkingLevel::High); + assert!(config.validate().is_err()); + } } 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 a9f7862bfb..c3e808fdd8 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 @@ -78,6 +78,7 @@ pub struct AcpxSidecarEvent { pub struct AcpxSidecarTransport { process: SupervisedProcess, request_timeout: Duration, + session_open_timeout: Duration, next_request_id: u64, last_event_sequence: u64, buffered_events: VecDeque, @@ -86,6 +87,107 @@ pub struct AcpxSidecarTransport { poisoned: bool, } +// A fresh Pi process verifies and copies its native closure before ACP admission. +// Reopen/recovery uses the same path. Ordinary sidecar requests retain their bound. +fn session_open_timeout(agent: &str, ordinary: Duration) -> Duration { + if agent == "pi" { + Duration::from_secs(60) + } else { + ordinary + } +} + +// Pi's provider catalog is caller-selected. The authenticated controller binds +// credential names (including custom models.json references); a Rust provider +// roster would silently drop credentials before the sidecar can validate them. +fn pi_credential_environment_keys(binding: Option<&str>) -> Result, LocalRunnerError> { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Binding { + schema: String, + agent: String, + session_id: String, + names: Vec, + } + let invalid = || LocalRunnerError::invalid("Pi credentials require a valid controller binding"); + let Some(raw) = binding else { + return Ok(Vec::new()); + }; + if raw.len() > 4_096 { + return Err(invalid()); + } + let value: Binding = serde_json::from_str(raw).map_err(|_| invalid())?; + if value.schema != "paperclip.acpx_credential_binding.v1" + || value.agent != "pi" + || !is_stable_id(&value.session_id, SHORT_STABLE_ID_CHARS) + || value.names.len() > 128 + { + return Err(invalid()); + } + let mut seen = BTreeSet::new(); + for name in &value.names { + let valid_name = name.len() <= 128 + && name.as_bytes().first().is_some_and(u8::is_ascii_uppercase) + && name + .bytes() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == b'_'); + // Match pi-provider-config.ts: prefixes such as LD_API_KEY are valid + // credentials; only the reserved process controls are rejected. + let protected_name = (name.starts_with("PAPERCLIP_") && name != "PAPERCLIP_PI_PROVIDERS") + || name.starts_with("NODE_") + || name.starts_with("NPM_") + || matches!( + name.as_str(), + "PATH" + | "HOME" + | "SHELL" + | "TMPDIR" + | "BASH_ENV" + | "ENV" + | "ZDOTDIR" + | "LD_AUDIT" + | "LD_LIBRARY_PATH" + | "LD_PRELOAD" + | "LD_DEBUG" + | "LD_DEBUG_OUTPUT" + | "LD_PROFILE" + | "LD_PROFILE_OUTPUT" + | "LD_TRACE_LOADED_OBJECTS" + | "LD_ORIGIN_PATH" + | "LD_BIND_NOW" + | "LD_BIND_NOT" + | "LD_DYNAMIC_WEAK" + | "LD_HWCAP_MASK" + | "LD_SHOW_AUXV" + | "LD_USE_LOAD_BIAS" + | "LD_VERBOSE" + | "LD_WARN" + | "LD_ASSUME_KERNEL" + | "LD_PREFER_MAP_32BIT_EXEC" + | "DYLD_INSERT_LIBRARIES" + | "DYLD_LIBRARY_PATH" + | "DYLD_FRAMEWORK_PATH" + | "DYLD_FALLBACK_LIBRARY_PATH" + | "DYLD_FALLBACK_FRAMEWORK_PATH" + | "DYLD_VERSIONED_LIBRARY_PATH" + | "DYLD_VERSIONED_FRAMEWORK_PATH" + | "DYLD_ROOT_PATH" + | "DYLD_IMAGE_SUFFIX" + | "DYLD_SHARED_CACHE_DIR" + | "GLIBC_TUNABLES" + | "GCONV_PATH" + | "LOCPATH" + | "NLSPATH" + ); + if !valid_name || protected_name || !seen.insert(name) { + return Err(invalid()); + } + } + // The sidecar still checks every name against Pi's pinned SDK/custom + // configuration and the exact session.open identity before provider launch. + Ok(value.names) +} + impl AcpxSidecarTransport { pub fn start(config: &AcpxSidecarTransportConfig) -> Result { Self::start_with_environment_keys(config, &[]) @@ -108,7 +210,7 @@ impl AcpxSidecarTransport { "PAPERCLIP_AI_PROVIDER_KEY", ], "grok" => &["XAI_API_KEY", "PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET"], - "pi" => &["OPENROUTER_API_KEY"], + "pi" => &[], "cursor" => &["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"], "copilot" => &["COPILOT_GITHUB_TOKEN"], _ => { @@ -163,8 +265,28 @@ impl AcpxSidecarTransport { "CLAUDE_CODE_SUBAGENT_MODEL", ]); } + let pi_keys = if agent == "pi" { + pi_credential_environment_keys( + std::env::var("PAPERCLIP_ACPX_CREDENTIAL_BINDING") + .ok() + .as_deref(), + )? + } else { + Vec::new() + }; + keys.extend(pi_keys.iter().map(String::as_str)); keys.extend_from_slice(credential_keys); - Self::start_with_environment_keys(config, &keys) + // Pi owns a native distribution copy, including a pending refresh at + // suspension. Allow its bounded cleanup to settle before group KILL; + // the ordinary two-second grace can cut off deletion mid-tree. + config.validate()?; + let mut launch_config = config.clone(); + if agent == "pi" { + launch_config.shutdown_grace = Duration::from_secs(30); + } + let mut transport = Self::start_with_environment_keys(&launch_config, &keys)?; + transport.session_open_timeout = session_open_timeout(agent, config.request_timeout); + Ok(transport) } fn start_with_environment_keys( @@ -191,6 +313,7 @@ impl AcpxSidecarTransport { Ok(Self { process, request_timeout: config.request_timeout, + session_open_timeout: config.request_timeout, next_request_id: 1, last_event_sequence: 0, buffered_events: VecDeque::new(), @@ -200,6 +323,14 @@ impl AcpxSidecarTransport { }) } + fn command_timeout(&self, command: GeneratedAcpxSidecarCommand) -> Duration { + if command == GeneratedAcpxSidecarCommand::SessionOpen { + self.session_open_timeout + } else { + self.request_timeout + } + } + pub fn process_id(&self) -> u32 { self.process.id() } @@ -295,7 +426,8 @@ impl AcpxSidecarTransport { })?; self.next_request_id = request_id + 1; - let deadline = Instant::now() + self.request_timeout; + let timeout = self.command_timeout(command); + let deadline = Instant::now() + timeout; loop { let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { @@ -795,6 +927,67 @@ fn response_error_classification(error: &ResponseError) -> &'static str { #[cfg(test)] mod tests { + #[test] + fn pi_credentials_require_a_bounded_binding_without_process_control_variables() { + assert!(pi_credential_environment_keys(None).unwrap().is_empty()); + for name in [ + "NODE_OPTIONS", + "PATH", + "HOME", + "LD_PRELOAD", + "DYLD_INSERT_LIBRARIES", + "PAPERCLIP_NATIVE_MCP_TOKEN", + "INVALID-NAME", + ] { + let binding = json!({"schema":"paperclip.acpx_credential_binding.v1", "agent":"pi", "sessionId":"session-1", "names":[name]}); + assert!(pi_credential_environment_keys(Some(&binding.to_string())).is_err()); + } + for binding in [ + json!({"schema":"other", "agent":"pi", "sessionId":"session-1", "names":[]}), + json!({"schema":"paperclip.acpx_credential_binding.v1", "agent":"cursor", "sessionId":"session-1", "names":[]}), + json!({"schema":"paperclip.acpx_credential_binding.v1", "agent":"pi", "sessionId":"session-1", "names":["MY_PI_KEY", "MY_PI_KEY"]}), + json!({"schema":"paperclip.acpx_credential_binding.v1", "agent":"pi", "sessionId":"session-1", "names":[], "extra":true}), + ] { + assert!(pi_credential_environment_keys(Some(&binding.to_string())).is_err()); + } + assert!(pi_credential_environment_keys(Some(&"x".repeat(4_097))).is_err()); + } + + #[test] + fn pi_loader_and_shell_controls_match_the_controller_reservations() { + let names: Vec = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../test-fixtures/pi-acp/reserved-credential-names.json" + ))) + .unwrap(); + for name in names { + let binding = json!({"schema":"paperclip.acpx_credential_binding.v1", "agent":"pi", "sessionId":"session-1", "names":[name]}); + assert!(pi_credential_environment_keys(Some(&binding.to_string())).is_err()); + } + } + + #[test] + fn pi_custom_credential_names_follow_the_controller_contract() { + let names = vec!["LD_API_KEY", "DYLD_API_KEY", "MY_PI_SERVICE_KEY"]; + let binding = json!({"schema":"paperclip.acpx_credential_binding.v1", "agent":"pi", "sessionId":"session-1", "names":names}); + assert_eq!( + pi_credential_environment_keys(Some(&binding.to_string())).unwrap(), + names + ); + } + + #[test] + fn only_pi_cold_open_gets_the_longer_admission_budget() { + let ordinary = Duration::from_secs(30); + assert_eq!( + session_open_timeout("pi", ordinary), + Duration::from_secs(60) + ); + for agent in ["claude", "codex", "grok", "cursor", "copilot"] { + assert_eq!(session_open_timeout(agent, ordinary), ordinary); + } + } + use super::*; #[test] 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 b351e34d8e..86a932bad1 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,26 @@ fn run() -> Result<(), Box> { .find(|pair| pair[0] == "--profile-digest") .map(|pair| pair[1].as_str()) .unwrap_or("sha256:1111111111111111111111111111111111111111111111111111111111111111"); + let suspend_delay_ms = args + .windows(2) + .find(|pair| pair[0] == "--suspend-delay-ms") + .map(|pair| pair[1].parse::()) + .transpose()?; + let mut opened_identity: Option = None; + let lifetime_fence_candidates = args + .windows(2) + .find(|pair| pair[0] == "--lifetime-fence-ports") + .map(|pair| { + pair[1] + .split(',') + .map(str::parse::) + .collect::, _>>() + }) + .transpose()? + .unwrap_or_else(|| vec![60001, 60002, 60003]); + let lifetime_fence_candidates: [u16; 3] = lifetime_fence_candidates + .try_into() + .map_err(|_| "lifetime fence requires three ports")?; // 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 @@ -71,6 +91,7 @@ fn run() -> Result<(), Box> { &request, mode, profile_digest, + lifetime_fence_candidates, &mut session_identity, ) }; @@ -136,6 +157,7 @@ fn run() -> Result<(), Box> { &request, mode, profile_digest, + lifetime_fence_candidates, &mut session_identity, ), )?; @@ -207,7 +229,7 @@ fn run() -> Result<(), Box> { "hasToken": std::env::var("PAPERCLIP_NATIVE_MCP_TOKEN").is_ok(), "hasUnrelatedSecret": std::env::var("UNRELATED_EVAL_SECRET").is_ok(), "credentialBinding": std::env::var("PAPERCLIP_ACPX_CREDENTIAL_BINDING").ok(), - "credentialKeys": (["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY", "OPENROUTER_API_KEY", "CURSOR_API_KEY", "CURSOR_AUTH_TOKEN", "COPILOT_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"].into_iter().filter(|key| std::env::var(key).is_ok()).collect::>()), + "credentialKeys": (["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "MY_PI_SERVICE_KEY", "LD_API_KEY", "DYLD_API_KEY", "PAPERCLIP_PI_PROVIDERS", "CURSOR_API_KEY", "CURSOR_AUTH_TOKEN", "COPILOT_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"].into_iter().filter(|key| std::env::var(key).is_ok()).collect::>()), } }), )?; @@ -262,6 +284,9 @@ fn run() -> Result<(), Box> { } "bootstrap" | "goals" + | "bootstrap-wrong-pi-thinking" + | "bootstrap-missing-pi-thinking" + | "bootstrap-alias-pi-thinking" | "bootstrap-wrong-model" | "bootstrap-wrong-run" | "controls" @@ -299,6 +324,9 @@ fn run() -> Result<(), Box> { | "resolutions-error-redaction" | "resolutions-projected-id" | "resolutions-wrong-ack" + | "resolutions-snapshot-wrong-session" + | "resolutions-snapshot-wrong-turn" + | "resolutions-snapshot-missing-callbacks" | "suspend" | "suspend-wrong-ack" | "suspend-wrong-identity" @@ -318,17 +346,25 @@ fn run() -> Result<(), Box> { )?; next_sequence += 1; } - write_json( - &mut stdout, - &bootstrap_success( - id, - command, - &request, - mode, - profile_digest, - &mut session_identity, - ), - )?; + let mut response = bootstrap_success( + id, + command, + &request, + mode, + profile_digest, + lifetime_fence_candidates, + &mut session_identity, + ); + if command == "session.open" { + opened_identity = response.pointer("/result/identity").cloned(); + } + if command == "session.suspend" { + if let Some(delay_ms) = suspend_delay_ms { + std::thread::sleep(Duration::from_millis(delay_ms)); + response["result"]["identity"] = opened_identity.clone().unwrap(); + } + } + write_json(&mut stdout, &response)?; let params = request.get("params").unwrap_or(&Value::Null); let turn_id = params .get("turnId") @@ -698,15 +734,7 @@ fn run() -> Result<(), Box> { )?; next_sequence += 1; } - if command == "turn.start" - && matches!( - mode, - "resolutions" - | "resolutions-error-redaction" - | "resolutions-projected-id" - | "resolutions-wrong-ack" - ) - { + if command == "turn.start" && mode.starts_with("resolutions") { for (event_type, payload) in [ ( "runtime.tool_called", @@ -777,6 +805,7 @@ fn bootstrap_success( request: &Value, mode: &str, profile_digest: &str, + lifetime_fence_candidates: [u16; 3], session_identity: &mut Value, ) -> Value { if command == "permission.resolve" { @@ -827,7 +856,13 @@ fn bootstrap_success( "effectiveModel": if mode == "bootstrap-wrong-model" { "wrong-model" } else { model }, "permissionMode": params.get("permissionMode"), "mode": params.get("mode"), - "providerLifetimeFenceCandidates": [60001, 60002, 60003], + "piThinkingLevel": match mode { + "bootstrap-wrong-pi-thinking" => json!("high"), + "bootstrap-missing-pi-thinking" => Value::Null, + "bootstrap-alias-pi-thinking" => json!("medium"), + _ => params.get("piThinkingLevel").cloned().unwrap_or(Value::Null), + }, + "providerLifetimeFenceCandidates": lifetime_fence_candidates, }, "status": {}, "turnControls": {"steering": matches!(mode, "controls" | "controls-wrong-ack" | "controls-downgrade"), "queuedFollowUp":matches!(mode, "controls" | "controls-wrong-ack" | "controls-downgrade")}, @@ -850,6 +885,21 @@ fn bootstrap_success( "turn.cancel" => { json!({"cancelled":mode != "turns-wrong-cancel", "sessionClosed":matches!(mode, "turns-retired" | "turns-retired-terminal-first")}) } + "session.snapshot" => { + let mut identity = session_identity.clone(); + if mode == "resolutions-snapshot-wrong-session" { + identity["acpxRecordId"] = json!("other-record"); + } + json!({ + "identity": identity, + "runId":"run-1", + "turnId":if mode == "resolutions-snapshot-wrong-turn" {"other-turn"} else {"turn-1"}, + "pendingRuntimeRequests":if mode == "resolutions-snapshot-missing-callbacks" {Value::Null} else { + json!([{"requestId":if mode == "resolutions-projected-id" {PROJECTED_INPUT_PROVIDER_ID} else {"input-1"}, + "type":"input","turnId":"turn-1"}]) + } + }) + } "session.suspend" => { let mut identity = session_identity.clone(); if mode == "suspend-missing-identity" { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index 1da99fcaad..61a8e8d0d1 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -27,9 +27,9 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_server_package: "pi-acp", agent_server_version: "0.0.33", agent_runtime_package: Some("@earendil-works/pi-coding-agent"), - agent_runtime_version: Some("0.84.2"), + agent_runtime_version: Some("1.0.0"), command_digest: - "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f", + "sha256:465ae72460f05cae961873f09cd7cd3652dc3a6949930b7ee16303925cd3dd0e", requires_provider_policy: true, }, "cursor" => AcpxReleaseProfile { @@ -38,7 +38,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: None, agent_runtime_version: None, command_digest: - "sha256:a5e70580e4933a1a9248cd3c1b16500c6c93e1e14913e0a98cd5ef878bd53d39", + "sha256:ac8092119542c8fbe95dae18ba5ef4d3689803fec56b7d2735fa193eea42f59b", requires_provider_policy: true, }, "copilot" => AcpxReleaseProfile { @@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: None, agent_runtime_version: None, command_digest: - "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + "sha256:8591f9a78a16aac4cf558733cd512f09def483fc11c673504bf93be7476d994c", requires_provider_policy: true, }, "claude" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs index 415a72186b..372c96ea32 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs @@ -40,6 +40,8 @@ pub(crate) fn is_node_interpreter(path: &Path) -> bool { pub struct VerifiedProcessArtifact { display_path: PathBuf, file: Arc, + #[cfg(target_os = "macos")] + executable: Option>, } impl VerifiedProcessArtifact { @@ -65,8 +67,246 @@ impl VerifiedProcessArtifact { Ok(Self { display_path, file: Arc::new(file), + #[cfg(target_os = "macos")] + executable: None, }) } + + /// Executable snapshots need a named image on Darwin. Construct that image + /// once, beside the source runtime for loader-relative shared libraries. + pub fn snapshot_verified_executable( + display_path: PathBuf, + file: File, + expected_sha256: &str, + ) -> Result { + #[cfg(target_os = "macos")] + { + let mut file = file; + file.seek(SeekFrom::Start(0)) + .map_err(|error| snapshot_error(&display_path, error))?; + let executable = Arc::new(NamedExecutableSnapshot::create( + &display_path, + &mut file, + expected_sha256, + )?); + Ok(Self { + display_path, + file: executable.file.clone(), + executable: Some(executable), + }) + } + #[cfg(not(target_os = "macos"))] + Self::snapshot_verified(display_path, file, expected_sha256) + } +} + +#[cfg(target_os = "macos")] +#[derive(Debug)] +struct NamedExecutablePath { + parent: File, + path: PathBuf, + basename: std::ffi::OsString, + device: u64, + inode: u64, +} + +#[cfg(target_os = "macos")] +impl NamedExecutablePath { + fn open_image(&self) -> Result { + use rustix::fs::{openat, Mode, OFlags}; + let fd = openat( + &self.parent, + &self.basename, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|error| snapshot_error(&self.path, error))?; + Ok(File::from(fd)) + } + + fn validate(&self) -> Result { + let parent_path = self.path.parent().expect("named image has a parent"); + let visible_parent = + fs::symlink_metadata(parent_path).map_err(|error| snapshot_error(&self.path, error))?; + let held_parent = self + .parent + .metadata() + .map_err(|error| snapshot_error(&self.path, error))?; + if !visible_parent.is_dir() + || visible_parent.dev() != held_parent.dev() + || visible_parent.ino() != held_parent.ino() + || visible_parent.permissions().mode() & 0o022 != 0 + { + return Err(snapshot_error(&self.path, "executable parent changed")); + } + let image = self.open_image()?; + let metadata = image + .metadata() + .map_err(|error| snapshot_error(&self.path, error))?; + if !metadata.is_file() + || metadata.dev() != self.device + || metadata.ino() != self.inode + || metadata.permissions().mode() & 0o777 != 0o500 + { + return Err(snapshot_error(&self.path, "executable image changed")); + } + Ok(image) + } +} + +#[cfg(target_os = "macos")] +impl Drop for NamedExecutablePath { + fn drop(&mut self) { + // Resolve relative to the retained directory, never through a replaced + // parent pathname. A substituted image does not belong to this guard. + if let Ok(file) = self.open_image() { + if let Ok(metadata) = file.metadata() { + if metadata.is_file() + && metadata.dev() == self.device + && metadata.ino() == self.inode + { + let _ = rustix::fs::unlinkat( + &self.parent, + &self.basename, + rustix::fs::AtFlags::empty(), + ); + } + } + } + } +} + +#[cfg(target_os = "macos")] +#[derive(Debug)] +struct NamedExecutableSnapshot { + image: NamedExecutablePath, + file: Arc, + expected_sha256: String, + expected_len: u64, +} + +#[cfg(target_os = "macos")] +impl NamedExecutableSnapshot { + fn create( + display_path: &Path, + source: &mut File, + expected_sha256: &str, + ) -> Result { + use rustix::fs::{open, openat, Mode, OFlags}; + let parent_path = display_path + .parent() + .ok_or_else(|| snapshot_error(display_path, "executable has no parent"))?; + let parent = File::from( + open( + parent_path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|error| snapshot_error(display_path, error))?, + ); + let parent_metadata = parent + .metadata() + .map_err(|error| snapshot_error(display_path, error))?; + if parent_metadata.permissions().mode() & 0o022 != 0 { + return Err(snapshot_error( + display_path, + "executable parent is group- or world-writable", + )); + } + let basename = std::ffi::OsString::from(format!( + ".paperclip-verified-executable-{}", + Uuid::new_v4().simple() + )); + let path = parent_path.join(&basename); + let mut writable = File::from( + openat( + &parent, + &basename, + OFlags::RDWR | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::RUSR | Mode::WUSR | Mode::XUSR, + ) + .map_err(|error| snapshot_error(display_path, error))?, + ); + // If fstat itself fails, ownership cannot be proven. Fail closed and + // leave the randomized entry rather than unlink a possibly substituted + // name without its inode identity. + let metadata = writable + .metadata() + .map_err(|error| snapshot_error(display_path, error))?; + let image = NamedExecutablePath { + parent, + path, + basename, + device: metadata.dev(), + inode: metadata.ino(), + }; + copy_verified(source, &mut writable, display_path, expected_sha256)?; + writable + .sync_all() + .map_err(|error| snapshot_error(display_path, error))?; + rustix::fs::fchmod(&writable, Mode::RUSR | Mode::XUSR) + .map_err(|error| snapshot_error(display_path, error))?; + let file = Arc::new(image.validate()?); + drop(writable); + let expected_len = file + .metadata() + .map_err(|error| snapshot_error(display_path, error))? + .len(); + Ok(Self { + image, + file, + expected_sha256: expected_sha256.to_owned(), + expected_len, + }) + } + + fn validate_before_spawn(&self) -> Result<(), LocalRunnerError> { + self.image.validate()?; + let before = self + .file + .metadata() + .map_err(|error| snapshot_error(&self.image.path, error))?; + if before.len() != self.expected_len { + return Err(snapshot_error( + &self.image.path, + "executable length changed", + )); + } + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + let mut offset = 0; + while offset < self.expected_len { + let remaining = (self.expected_len - offset).min(buffer.len() as u64) as usize; + let count = self + .file + .read_at(&mut buffer[..remaining], offset) + .map_err(|error| snapshot_error(&self.image.path, error))?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + offset += count as u64; + } + let after = self + .file + .metadata() + .map_err(|error| snapshot_error(&self.image.path, error))?; + if format!("sha256:{:x}", digest.finalize()) != self.expected_sha256 + || before.len() != after.len() + || offset != after.len() + || before.mtime() != after.mtime() + || before.mtime_nsec() != after.mtime_nsec() + || before.ctime() != after.ctime() + || before.ctime_nsec() != after.ctime_nsec() + { + return Err(snapshot_error( + &self.image.path, + "executable digest or metadata changed", + )); + } + self.image.validate()?; + Ok(()) + } } #[cfg(target_os = "linux")] @@ -232,11 +472,18 @@ impl VerifiedProcessLaunch { #[cfg(target_os = "linux")] inherited.push(program_fd); #[cfg(target_os = "macos")] - let program_snapshot = materialize_executable(&self.program)?; + let named_executable = self.program.executable.clone(); #[cfg(target_os = "macos")] - let program = program_snapshot.path.clone(); + let mut temporary_executables = Vec::new(); #[cfg(target_os = "macos")] - let mut temporary_executables = vec![program_snapshot]; + let program = if let Some(executable) = &named_executable { + executable.image.path.clone() + } else { + let snapshot = materialize_executable(&self.program)?; + let path = snapshot.path.clone(); + temporary_executables.push(snapshot); + path + }; let mut args = Vec::with_capacity(self.args.len()); for argument in &self.args { match argument { @@ -269,12 +516,18 @@ impl VerifiedProcessLaunch { } } } + #[cfg(target_os = "macos")] + if let Some(executable) = &named_executable { + executable.validate_before_spawn()?; + } Ok(InheritedCommand { program, args, _inherited: inherited, #[cfg(target_os = "macos")] temporary_executables, + #[cfg(target_os = "macos")] + named_executable, }) } } @@ -286,6 +539,8 @@ struct InheritedCommand { _inherited: Vec, #[cfg(target_os = "macos")] temporary_executables: Vec, + #[cfg(target_os = "macos")] + named_executable: Option>, } #[cfg(target_os = "macos")] @@ -641,6 +896,8 @@ pub struct SupervisedProcess { finished: bool, #[cfg(target_os = "macos")] _temporary_executables: Vec, + #[cfg(target_os = "macos")] + _named_executable: Option>, } impl SupervisedProcess { @@ -722,6 +979,7 @@ impl SupervisedProcess { if let Ok(process) = result.as_mut() { process._temporary_executables = std::mem::take(&mut inherited.temporary_executables); + process._named_executable = inherited.named_executable.take(); } drop(inherited); result @@ -827,6 +1085,8 @@ impl SupervisedProcess { finished: false, #[cfg(target_os = "macos")] _temporary_executables: Vec::new(), + #[cfg(target_os = "macos")] + _named_executable: None, }) } @@ -1101,6 +1361,260 @@ mod tests { assert!(!process.stdout_drained()); } + // These tests exercise file admission and ownership only. They never launch + // a native runner, shell, Node executable, provider, or child process. + #[cfg(target_os = "macos")] + mod darwin_named_snapshot { + use super::*; + use std::os::unix::fs::symlink; + + struct Fixture { + directory: PathBuf, + } + impl Fixture { + fn new() -> Self { + let directory = std::env::temp_dir().join(format!( + "paperclip-darwin-snapshot-{}", + Uuid::new_v4().simple() + )); + fs::create_dir(&directory).unwrap(); + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).unwrap(); + Self { + directory: fs::canonicalize(directory).unwrap(), + } + } + fn artifact(&self) -> VerifiedProcessArtifact { + let path = self.directory.join("node"); + fs::write(&path, b"authenticated executable bytes").unwrap(); + VerifiedProcessArtifact::snapshot_verified_executable( + path.clone(), + File::open(path).unwrap(), + &format!( + "sha256:{:x}", + Sha256::digest(b"authenticated executable bytes") + ), + ) + .unwrap() + } + fn image_count(&self) -> usize { + fs::read_dir(&self.directory) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".paperclip-verified-executable-") + }) + .count() + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.directory); + } + } + + #[test] + fn source_changes_and_closed_source_do_not_change_single_image() { + let fixture = Fixture::new(); + let artifact = fixture.artifact(); + let executable = artifact.executable.as_ref().unwrap(); + let source = fixture.directory.join("node"); + fs::write(&source, b"overwrite original inode").unwrap(); + fs::rename(&source, fixture.directory.join("retired-node")).unwrap(); + fs::write(&source, b"replacement source inode").unwrap(); + fs::remove_file(&source).unwrap(); + executable.validate_before_spawn().unwrap(); + assert_eq!( + executable.image.path.parent(), + Some(fixture.directory.as_path()) + ); + assert_eq!(fixture.image_count(), 1); + let launch = VerifiedProcessLaunch::new(artifact, vec![]); + let inherited = launch.inherited_command().unwrap(); + assert_eq!( + fixture.image_count(), + 1, + "admission must not materialize another image" + ); + assert_eq!( + fs::read(&inherited.program).unwrap(), + b"authenticated executable bytes" + ); + } + + #[test] + fn same_inode_mutation_is_rejected_even_after_restoring_readonly_mode() { + let fixture = Fixture::new(); + let artifact = fixture.artifact(); + let executable = artifact.executable.as_ref().unwrap(); + fs::set_permissions(&executable.image.path, fs::Permissions::from_mode(0o700)).unwrap(); + fs::write( + &executable.image.path, + vec![b'X'; executable.expected_len as usize], + ) + .unwrap(); + fs::set_permissions(&executable.image.path, fs::Permissions::from_mode(0o500)).unwrap(); + assert!(executable.validate_before_spawn().is_err()); + } + + #[test] + fn replacement_image_is_rejected_and_not_removed_by_drop() { + let fixture = Fixture::new(); + let artifact = fixture.artifact(); + let path = artifact.executable.as_ref().unwrap().image.path.clone(); + fs::rename(&path, fixture.directory.join("retired-image")).unwrap(); + fs::write(&path, b"replacement").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o500)).unwrap(); + assert!(artifact + .executable + .as_ref() + .unwrap() + .validate_before_spawn() + .is_err()); + drop(artifact); + assert_eq!(fs::read(&path).unwrap(), b"replacement"); + } + + #[test] + fn symlink_image_is_rejected_and_its_target_and_link_survive_drop() { + let fixture = Fixture::new(); + let artifact = fixture.artifact(); + let path = artifact.executable.as_ref().unwrap().image.path.clone(); + fs::remove_file(&path).unwrap(); + let target = fixture.directory.join("unrelated"); + fs::write(&target, b"unrelated").unwrap(); + symlink(&target, &path).unwrap(); + assert!(artifact + .executable + .as_ref() + .unwrap() + .validate_before_spawn() + .is_err()); + drop(artifact); + assert!(fs::symlink_metadata(&path) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(fs::read(&target).unwrap(), b"unrelated"); + } + + #[test] + fn directory_image_is_rejected_and_survives_drop() { + let fixture = Fixture::new(); + let artifact = fixture.artifact(); + let executable = artifact.executable.as_ref().unwrap(); + let path = executable.image.path.clone(); + fs::remove_file(&path).unwrap(); + // rustix does not expose safe FIFO creation on Darwin. This tests + // nonregular-entry rejection; FIFO-specific calibration remains + // separate. Production opens retain NONBLOCK for FIFO substitution. + fs::create_dir(&path).unwrap(); + assert!(executable.validate_before_spawn().is_err()); + drop(artifact); + assert!(fs::symlink_metadata(&path).unwrap().is_dir()); + } + + #[test] + fn writable_mode_is_rejected_even_with_original_bytes() { + let fixture = Fixture::new(); + let artifact = fixture.artifact(); + let executable = artifact.executable.as_ref().unwrap(); + fs::set_permissions(&executable.image.path, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(executable.validate_before_spawn().is_err()); + fs::set_permissions(&executable.image.path, fs::Permissions::from_mode(0o500)).unwrap(); + executable.validate_before_spawn().unwrap(); + } + + #[test] + fn replaced_parent_fails_admission_and_cleanup_uses_held_parent() { + let fixture = Fixture::new(); + let parent = fixture.directory.join("runtime"); + fs::create_dir(&parent).unwrap(); + let source = parent.join("node"); + fs::write(&source, b"original").unwrap(); + let artifact = VerifiedProcessArtifact::snapshot_verified_executable( + source.clone(), + File::open(&source).unwrap(), + &format!("sha256:{:x}", Sha256::digest(b"original")), + ) + .unwrap(); + let path = artifact.executable.as_ref().unwrap().image.path.clone(); + let retired = fixture.directory.join("retired-runtime"); + fs::rename(&parent, &retired).unwrap(); + fs::create_dir(&parent).unwrap(); + fs::write(&path, b"replacement parent image").unwrap(); + assert!(artifact + .executable + .as_ref() + .unwrap() + .validate_before_spawn() + .is_err()); + drop(artifact); + assert_eq!(fs::read(&path).unwrap(), b"replacement parent image"); + assert!(!retired.join(path.file_name().unwrap()).exists()); + } + + #[test] + fn launch_clones_and_inherited_owner_keep_image_until_last_drop() { + let fixture = Fixture::new(); + let launch = VerifiedProcessLaunch::new(fixture.artifact(), vec![]); + let clone = launch.clone(); + let mut inherited = launch.inherited_command().unwrap(); + let path = inherited.program.clone(); + // This is the same ownership transfer performed after spawn succeeds. + let process_owner = inherited.named_executable.take(); + drop(inherited); + drop(launch); + assert!(path.exists()); + drop(clone); + assert!( + path.exists(), + "descendant runtime pathname must remain live" + ); + process_owner + .as_ref() + .unwrap() + .validate_before_spawn() + .unwrap(); + drop(process_owner); + assert!(!path.exists()); + } + + #[test] + fn digest_failure_and_late_abort_remove_owned_image() { + let fixture = Fixture::new(); + let source = fixture.directory.join("node"); + fs::write(&source, b"original").unwrap(); + assert!(VerifiedProcessArtifact::snapshot_verified_executable( + source.clone(), + File::open(source).unwrap(), + "sha256:wrong" + ) + .is_err()); + assert_eq!(fixture.image_count(), 0); + let launch = VerifiedProcessLaunch::new(fixture.artifact(), vec![]); + let inherited = launch.inherited_command().unwrap(); + let path = inherited.program.clone(); + drop(launch); + assert!(path.exists()); + drop(inherited); // Includes the spawn-error/late-abort ownership path. + assert!(!path.exists()); + } + + #[test] + fn commonjs_snapshots_stay_anonymous() { + let fixture = Fixture::new(); + let script = verified_artifact( + &fixture.directory.join("sidecar.cjs"), + b"module.exports = {};\n", + ); + assert!(script.executable.is_none()); + assert_eq!(script.file.metadata().unwrap().nlink(), 0); + } + } + fn verified_artifact(path: &Path, bytes: &[u8]) -> VerifiedProcessArtifact { fs::write(path, bytes).unwrap(); let digest = format!("sha256:{:x}", Sha256::digest(bytes)); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs index 85b114b132..23268296bd 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -3594,7 +3594,9 @@ impl CodexCommandExecutor { state.receipt_limit_interrupt_accepted = false; state.receipt_limit_interrupt_attempts = 0; state.receipt_limit_interrupt_deadline_unix_ms = None; - state.lifecycle = "provider_exited".to_owned(); + // Deadline settlement retires this run permanently. Polling its terminal + // outbox must not reopen a provider that still reports the stopped turn. + state.lifecycle = "closed".to_owned(); let terminal_event_type = if interrupt_accepted { "turn.interrupted" } else { @@ -6308,7 +6310,7 @@ mod tests { provider: "codex".to_owned(), driver: "codex_app_server".to_owned(), provider_version: "test".to_owned(), - command: PathBuf::from("codex"), + command: PathBuf::from("/definitely-missing-receipt-deadline-provider"), args: vec!["app-server".to_owned()], cwd: std::env::current_dir() .unwrap() @@ -6355,7 +6357,7 @@ mod tests { executor.maintain_backpressured_provider().unwrap(); let state = executor.state.as_ref().unwrap(); - assert_eq!(state.lifecycle, "provider_exited"); + assert_eq!(state.lifecycle, "closed"); assert!(state.active_provider_turn_id.is_none()); assert!(!state.receipt_limit_interrupt_pending); assert!(state.pending_events.iter().any(|event| { @@ -6373,6 +6375,13 @@ mod tests { settled ); assert!(executor.provider.is_none()); + // Reading terminal evidence must not restart work whose interruption + // exhausted its deadline, even after a controller reconnects. + assert!(!executor.poll_events().unwrap().is_empty()); + assert!(executor.provider.is_none()); + let mut restarted = CodexCommandExecutor::new(&directory); + assert!(!restarted.poll_events().unwrap().is_empty()); + assert!(restarted.provider.is_none()); fs::remove_dir_all(directory).unwrap(); } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/qualified_launch.rs b/packages/paperclip-runner/runner/crates/runner-core/src/qualified_launch.rs index c7f72d3152..570080ff87 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/qualified_launch.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/qualified_launch.rs @@ -9,6 +9,21 @@ use crate::process_supervisor::VerifiedProcessArtifact; pub fn verify_launch_artifact( artifact: &QualifiedLaunchArtifact, label: &str, +) -> Result { + verify_launch_artifact_with_role(artifact, label, false) +} + +pub fn verify_executable_launch_artifact( + artifact: &QualifiedLaunchArtifact, + label: &str, +) -> Result { + verify_launch_artifact_with_role(artifact, label, true) +} + +fn verify_launch_artifact_with_role( + artifact: &QualifiedLaunchArtifact, + label: &str, + executable: bool, ) -> Result { let source_metadata = fs::symlink_metadata(&artifact.path).map_err(|error| { DurableRunnerError::invalid(format!("failed to inspect qualified {label}: {error}")) @@ -49,8 +64,12 @@ pub fn verify_launch_artifact( "qualified {label} changed while it was opened" ))); } - VerifiedProcessArtifact::snapshot_verified(canonical, file, &artifact.sha256) - .map_err(|error| DurableRunnerError::invalid(error.to_string())) + let snapshot = if executable { + VerifiedProcessArtifact::snapshot_verified_executable(canonical, file, &artifact.sha256) + } else { + VerifiedProcessArtifact::snapshot_verified(canonical, file, &artifact.sha256) + }; + snapshot.map_err(|error| DurableRunnerError::invalid(error.to_string())) } #[cfg(unix)] 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 4c8a488e80..48daa24f6f 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 @@ -51,6 +51,7 @@ fn config(directory: &std::path::Path) -> AcpxProviderSessionConfig { working_directory: directory.to_owned(), permission_mode: AcpxPermissionMode::ApproveReads, mode: None, + pi_thinking_level: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), @@ -78,6 +79,7 @@ fn identity() -> AcpxProviderSessionIdentity { effective_model: "gpt-5.6-sol".to_owned(), permission_mode: Some(AcpxPermissionMode::ApproveReads), mode: None, + pi_thinking_level: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], } } @@ -290,3 +292,49 @@ fn mode_round_trips_checkpoint_and_rejects_changed_or_missing_recovery_mode() { assert!(recovered.admit_recovery(&other).is_err()); fs::remove_dir_all(directory).unwrap(); } + +#[test] +fn pi_thinking_level_round_trips_and_rejects_legacy_or_changed_recovery() { + use paperclip_runner_core::acpx_provider_session::{ + AcpxProviderRuntimePolicy, PiThinkingLevel, + }; + let directory = temporary_directory("pi-thinking"); + let mut config = config(&directory); + config.agent = "pi".to_owned(); + config.model = "openrouter/deepseek/deepseek-v4-flash-0731".to_owned(); + config.pi_thinking_level = Some(PiThinkingLevel::Low); + config.provider_policy = Some(AcpxProviderRuntimePolicy { read_only: false }); + let mut identity = identity(); + identity.requested_model = config.model.clone(); + identity.effective_model = config.model.clone(); + identity.pi_thinking_level = Some(PiThinkingLevel::Low); + let checkpoint = AcpxSuspensionCheckpoint::from_suspension(&config, identity.clone()).unwrap(); + let store = AcpxSuspensionCheckpointStore::new(&directory).unwrap(); + store.save(&checkpoint).unwrap(); + let recovered = store.load().unwrap().unwrap(); + assert_eq!(recovered.admit_recovery(&config).unwrap(), identity); + let wire = serde_json::to_value(&recovered).unwrap(); + assert_eq!(wire["identity"]["piThinkingLevel"], json!("low")); + for level in [ + None, + Some(PiThinkingLevel::Off), + Some(PiThinkingLevel::High), + Some(PiThinkingLevel::Max), + ] { + let mut changed_config = config.clone(); + changed_config.pi_thinking_level = level; + assert!(recovered.admit_recovery(&changed_config).is_err()); + let mut changed = wire.clone(); + changed["identity"]["piThinkingLevel"] = json!(level); + let changed: AcpxSuspensionCheckpoint = serde_json::from_value(changed).unwrap(); + assert!(changed.admit_recovery(&config).is_err()); + } + let mut legacy = wire; + legacy["identity"] + .as_object_mut() + .unwrap() + .remove("piThinkingLevel"); + let legacy: AcpxSuspensionCheckpoint = serde_json::from_value(legacy).unwrap(); + assert!(legacy.admit_recovery(&config).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 4fded95a36..f64f89e436 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 @@ -50,6 +50,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { working_directory: std::env::temp_dir(), permission_mode: AcpxPermissionMode::ApproveReads, mode: None, + pi_thinking_level: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), @@ -106,6 +107,32 @@ fn commits_each_resolution_only_after_sidecar_acknowledgement() { session.shutdown("test complete").unwrap(); } +#[test] +fn live_callback_snapshot_checks_the_surviving_sidecar_and_upstream_input_id() { + for mode in ["resolutions", "resolutions-projected-id"] { + let mut session = started(mode); + let live = session.verify_live_request_snapshot().unwrap(); + let provider_id = if mode == "resolutions-projected-id" { + "input / réquest" + } else { + "input-1" + }; + assert_eq!(live.get(provider_id).map(String::as_str), Some("input")); + assert_eq!(live.len(), 1); + session.shutdown("test complete").unwrap(); + assert!(session.verify_live_request_snapshot().is_err()); + } + for mode in [ + "resolutions-snapshot-wrong-session", + "resolutions-snapshot-wrong-turn", + "resolutions-snapshot-missing-callbacks", + ] { + let mut session = started(mode); + assert!(session.verify_live_request_snapshot().is_err(), "{mode}"); + session.shutdown("test complete").unwrap(); + } +} + #[test] fn projected_request_id_resolves_the_exact_upstream_sidecar_request() { let mut session = AcpxProviderSession::start(&config("resolutions-projected-id")).unwrap(); @@ -308,6 +335,18 @@ 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 == "pi" { + cfg.pi_thinking_level = + Some(paperclip_runner_core::acpx_provider_session::PiThinkingLevel::Low); + } if agent == "cursor" { cfg.mode = Some("agent".to_owned()); } @@ -344,6 +383,7 @@ fn permission_origin_is_bound_to_the_admitted_connection_and_survives_projection 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()) 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 911b765b74..71dcb93462 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 @@ -46,6 +46,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { working_directory: std::env::temp_dir(), permission_mode: AcpxPermissionMode::ApproveReads, mode: None, + pi_thinking_level: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), @@ -68,6 +69,7 @@ fn expected_identity() -> AcpxProviderSessionIdentity { effective_model: "gpt-5.6-sol".to_owned(), permission_mode: Some(AcpxPermissionMode::ApproveReads), mode: None, + pi_thinking_level: None, provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], } } @@ -162,6 +164,11 @@ fn bootstraps_unlisted_models_confirmed_by_the_sidecar_for_every_agent() { let mut selected = config("bootstrap"); selected.agent = agent.to_owned(); selected.model = "custom/model[context=272k,reasoning=medium]".to_owned(); + selected.pi_thinking_level = if agent == "pi" { + Some(paperclip_runner_core::acpx_provider_session::PiThinkingLevel::Low) + } else { + None + }; selected.provider_policy = Some(AcpxProviderRuntimePolicy { read_only: false }); let mut session = AcpxProviderSession::start(&selected).unwrap(); assert_eq!(session.identity().requested_model, selected.model); @@ -265,15 +272,26 @@ fn check_tool_receiver_admission(oversized: bool) { assert!(session.state().pending_tool("call-admission").is_some()); session.deliver_tool_result(&result).unwrap(); assert!(!session.state().has_pending_tools()); - // An exact durable delivery replay is acknowledged without a second - // sidecar resolution. The command journal below proves that boundary. - session.deliver_tool_result(&result).unwrap(); - let mut changed = result.clone(); - changed.result = json!({"id":"different-issue"}); - assert!(session.deliver_tool_result(&changed).is_err()); - changed = result.clone(); - changed.operation_id = "issues.update".to_owned(); - assert!(session.deliver_tool_result(&changed).is_err()); + session + .deliver_tool_result(&result) + .expect("an identical receipt retry must be idempotent"); + let mut changed_payload = result.clone(); + changed_payload.result = json!({"id":"another-issue"}); + let mut changed_operation = result.clone(); + changed_operation.operation_id = "issues.write".to_owned(); + let mut changed_error = result.clone(); + changed_error.is_error = true; + for conflicting in [changed_payload, changed_operation, changed_error] { + let error = session + .deliver_tool_result(&conflicting) + .unwrap_err() + .to_string(); + assert!( + error.contains("conflicting duplicate tool result"), + "{error}" + ); + } + assert!(!session.state().has_pending_tools()); } session.shutdown("admission test complete").unwrap(); let rows: Vec = std::fs::read_to_string(&journal) @@ -324,3 +342,41 @@ fn receiver_rejects_sub_megabyte_tool_event_before_pending_admission_or_resoluti fn receiver_admits_normal_tool_event_and_resolves_only_correlated_call_once() { check_tool_receiver_admission(false); } + +#[test] +fn pi_thinking_effective_identity_is_admitted_before_any_turn() { + use paperclip_runner_core::acpx_provider_session::{ + AcpxProviderRuntimePolicy, PiThinkingLevel, + }; + let mut cfg = config("bootstrap"); + cfg.agent = "pi".to_owned(); + cfg.model = "openrouter/deepseek/deepseek-v4-flash-0731".to_owned(); + cfg.pi_thinking_level = Some(PiThinkingLevel::Low); + cfg.provider_policy = Some(AcpxProviderRuntimePolicy { read_only: false }); + let mut session = AcpxProviderSession::start(&cfg).unwrap(); + assert_eq!( + session.identity().pi_thinking_level, + Some(PiThinkingLevel::Low) + ); + assert!(session.state().active_turn_id().is_none()); + let identity = session.identity().clone(); + session.shutdown("thinking mode admitted").unwrap(); + cfg.expected_identity = Some(identity); + let mut restored = AcpxProviderSession::start(&cfg).unwrap(); + assert_eq!( + restored.identity().pi_thinking_level, + Some(PiThinkingLevel::Low) + ); + assert!(restored.state().active_turn_id().is_none()); + restored + .shutdown("restored thinking mode admitted") + .unwrap(); + for mode in [ + "bootstrap-wrong-pi-thinking", + "bootstrap-missing-pi-thinking", + "bootstrap-alias-pi-thinking", + ] { + cfg.transport.args = vec!["--mode".to_owned(), mode.to_owned()]; + assert!(start_error(&cfg).contains("identity")); + } +} 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 3af110c46a..e8e65d7628 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 @@ -26,6 +26,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { working_directory: std::env::temp_dir(), permission_mode: AcpxPermissionMode::ApproveReads, mode: None, + pi_thinking_level: None, permission_mode_pinned: true, provider_policy: None, system_instructions: "Complete the supplied task.".to_owned(), @@ -75,6 +76,20 @@ fn reaps_an_active_provider_generation_at_the_suspension_boundary() { assert!(session.shutdown("already terminated").is_ok()); } +#[test] +fn idle_suspension_cannot_retire_an_active_turn() { + let mut session = AcpxProviderSession::start(&config("suspend")).unwrap(); + session + .start_turn("turn-1", "Please help", &std::env::temp_dir()) + .unwrap(); + let error = session.terminate_idle_for_suspension().unwrap_err(); + assert!(error + .to_string() + .contains("settled turn and no pending requests")); + assert_eq!(session.state().active_turn_id(), Some("turn-1")); + session.shutdown("test complete").unwrap(); +} + #[test] fn fails_closed_when_the_suspension_acknowledgement_does_not_match() { for mode in ["suspend-wrong-ack", "suspend-wrong-identity"] { 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 4380191fb2..973011b98f 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 @@ -50,6 +50,11 @@ fn config(mode: &str) -> AcpxProviderSessionConfig { working_directory: std::env::temp_dir(), permission_mode: AcpxPermissionMode::ApproveReads, mode: None, + pi_thinking_level: if mode.starts_with("controls") { + Some(paperclip_runner_core::acpx_provider_session::PiThinkingLevel::Low) + } else { + None + }, permission_mode_pinned: true, provider_policy: if mode.starts_with("controls") { Some( diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs index 59497b1879..08ed56fdba 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs @@ -134,6 +134,55 @@ fn an_empty_event_poll_does_not_poison_the_transport() { transport.shutdown().expect("fake sidecar should stop"); } +#[cfg(unix)] +#[test] +fn pi_shutdown_allows_owned_snapshot_cleanup_after_the_ordinary_two_second_grace() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let marker = + std::env::temp_dir().join(format!("pi-sidecar-cleanup-{}-{nonce}", std::process::id())); + let ready = marker.with_extension("ready"); + std::fs::write(&marker, b"owned snapshot cleanup pending").unwrap(); + // TERM is delivered to the same owned process group as in suspension. + // Cleanup deliberately takes longer than the ordinary two-second grace. + let script = + "trap 'sleep 3; rm -- \"$1\"; exit 0' TERM; echo ready > \"$2\"; while :; do sleep 1; done"; + let config = AcpxSidecarTransportConfig { + command: PathBuf::from("/bin/sh"), + args: vec![ + "-c".into(), + script.into(), + "pi-cleanup-fixture".into(), + marker.to_string_lossy().into_owned(), + ready.to_string_lossy().into_owned(), + ], + verified_launch: None, + request_timeout: Duration::from_secs(30), + shutdown_grace: Duration::from_secs(2), + }; + let mut transport = AcpxSidecarTransport::start_for_agent(&config, "pi").unwrap(); + let startup = Instant::now(); + while !ready.exists() && startup.elapsed() < Duration::from_secs(2) { + std::thread::sleep(Duration::from_millis(5)); + } + assert!( + ready.exists(), + "fixture did not install its TERM cleanup handler" + ); + let started = Instant::now(); + transport.shutdown().unwrap(); + let removed = !marker.exists(); + let _ = std::fs::remove_file(&marker); + let _ = std::fs::remove_file(&ready); + assert!( + removed, + "Pi sidecar was killed before owned snapshot cleanup completed" + ); + assert!(started.elapsed() < Duration::from_secs(10)); +} + #[test] fn rejects_an_unbounded_event_poll_without_poisoning_the_transport() { let mut transport = transport("silent", Duration::from_secs(1)); @@ -235,7 +284,7 @@ fn assigned_gateway_binding_reaches_qualified_sidecar_without_unrelated_secrets( .env("UNRELATED_EVAL_SECRET", "must-not-cross-boundary") .env( "PAPERCLIP_ACPX_CREDENTIAL_BINDING", - "controller-session-binding", + r#"{"schema":"paperclip.acpx_credential_binding.v1","agent":"pi","sessionId":"session-1","names":["OPENROUTER_API_KEY","GEMINI_API_KEY","MY_PI_SERVICE_KEY","LD_API_KEY","DYLD_API_KEY","PAPERCLIP_PI_PROVIDERS"]}"#, ) .envs( [ @@ -244,6 +293,11 @@ fn assigned_gateway_binding_reaches_qualified_sidecar_without_unrelated_secrets( "OPENAI_API_KEY", "CODEX_API_KEY", "OPENROUTER_API_KEY", + "GEMINI_API_KEY", + "MY_PI_SERVICE_KEY", + "LD_API_KEY", + "DYLD_API_KEY", + "PAPERCLIP_PI_PROVIDERS", "CURSOR_API_KEY", "CURSOR_AUTH_TOKEN", "COPILOT_GITHUB_TOKEN", @@ -283,7 +337,14 @@ fn assigned_gateway_binding_reaches_qualified_sidecar_without_unrelated_secrets( let expected = match agent { "claude" => vec!["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], "codex" => vec!["OPENAI_API_KEY", "CODEX_API_KEY"], - "pi" => vec!["OPENROUTER_API_KEY"], + "pi" => vec![ + "OPENROUTER_API_KEY", + "GEMINI_API_KEY", + "MY_PI_SERVICE_KEY", + "LD_API_KEY", + "DYLD_API_KEY", + "PAPERCLIP_PI_PROVIDERS", + ], "cursor" => vec!["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"], "copilot" => vec!["COPILOT_GITHUB_TOKEN"], _ => unreachable!(), @@ -292,7 +353,9 @@ fn assigned_gateway_binding_reaches_qualified_sidecar_without_unrelated_secrets( assert_eq!( response["credentialBinding"], if matches!(agent, "pi" | "cursor" | "copilot") { - json!("controller-session-binding") + json!( + r#"{"schema":"paperclip.acpx_credential_binding.v1","agent":"pi","sessionId":"session-1","names":["OPENROUTER_API_KEY","GEMINI_API_KEY","MY_PI_SERVICE_KEY","LD_API_KEY","DYLD_API_KEY","PAPERCLIP_PI_PROVIDERS"]}"# + ) } else { serde_json::Value::Null } @@ -300,3 +363,32 @@ fn assigned_gateway_binding_reaches_qualified_sidecar_without_unrelated_secrets( sidecar.shutdown().unwrap(); } } + +#[test] +fn pi_ordinary_request_timeout_still_retires_the_sidecar() { + let mut sidecar = AcpxSidecarTransport::start_for_agent( + &AcpxSidecarTransportConfig { + command: PathBuf::from(env!("CARGO_BIN_EXE_fake-acpx-sidecar")), + args: vec!["--mode".to_owned(), "silent".to_owned()], + verified_launch: None, + request_timeout: Duration::from_millis(30), + shutdown_grace: Duration::from_millis(50), + }, + "pi", + ) + .unwrap(); + let started = Instant::now(); + let error = sidecar + .request(GeneratedAcpxSidecarCommand::Initialize, json!({})) + .unwrap_err(); + assert!(error.to_string().contains("timed out")); + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(sidecar + .request(GeneratedAcpxSidecarCommand::SessionOpen, json!({})) + .unwrap_err() + .to_string() + .contains("unavailable")); + sidecar + .shutdown() + .expect("timeout cleanup remains idempotent"); +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs index 34e3b46d0c..b4bc2a316b 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs @@ -5561,12 +5561,21 @@ fn receipt_limit_synthesizes_interrupted_after_an_accepted_terminal_deadline() { .expect("read bounded receipt-limit state"), ) .expect("parse bounded receipt-limit state"); - assert_eq!(persisted["lifecycle"], "provider_exited"); + assert_eq!(persisted["lifecycle"], "closed"); assert!(persisted["activeProviderTurnId"].is_null()); assert_eq!(persisted["receiptLimitInterruptPending"], false); assert_eq!(persisted["receiptLimitInterruptAttempts"], 0); assert!(persisted["receiptLimitInterruptDeadlineUnixMs"].is_null()); + let resume_calls = call_count(&directory, "thread/resume"); + poll_and_ack(&mut recovered).expect("closed fallback stays pollable"); + let mut reconnected = CodexCommandExecutor::with_runner_config(&directory, &runner_config); + poll_and_ack(&mut reconnected).expect("closed fallback stays pollable after reconnect"); + assert_eq!(call_count(&directory, "thread/resume"), resume_calls); + reconnected + .shutdown() + .expect("closed reconnect has no provider to stop"); + recovered .shutdown() .expect("bounded fallback stopped provider"); 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 ade0b172a7..b8c2e6b157 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:47306e6d2a9b59e8f9189f725ebb7a0a7f91826044d1739e1a35ab31f228ba1f"; + "sha256:465ae72460f05cae961873f09cd7cd3652dc3a6949930b7ee16303925cd3dd0e"; fn temporary_directory(label: &str) -> PathBuf { let nonce = SystemTime::now() @@ -235,6 +235,7 @@ fn pi_prepare_payload(directory: &Path, mode: &str) -> Value { provider["commandDigest"] = json!(PI_ACPX_DIGEST); provider["sidecarArgs"][3] = json!(PI_ACPX_DIGEST); provider["providerPolicy"] = json!({"readOnly":true}); + provider["piThinkingLevel"] = json!("low"); payload } @@ -1371,6 +1372,162 @@ fn rejects_pi_without_an_explicit_model_before_starting_a_sidecar() { fs::remove_dir_all(directory).unwrap(); } +#[test] +fn pi_thinking_change_cannot_attach_to_an_existing_provider_identity() { + let directory = temporary_directory("pi-thinking-attach"); + let config = pi_acpx_config(&directory, "bootstrap"); + let payload = pi_prepare_payload(&directory, "bootstrap"); + let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config); + executor + .execute(&command(1, "run.prepare", payload.clone())) + .unwrap(); + let opened = executor + .execute(&command(2, "session.open", json!({}))) + .unwrap(); + assert_eq!(opened.result["piThinkingLevel"], json!("low")); + assert_eq!( + opened.events[0].2["providerDescriptor"]["piThinkingLevel"], + json!("low") + ); + let state_path = directory.join("acpx-provider-state.json"); + let prior = fs::read(&state_path).unwrap(); + for (index, mode) in ["off", "high", "max"].into_iter().enumerate() { + let mut changed = payload.clone(); + changed["provider"]["piThinkingLevel"] = json!(mode); + let error = executor + .execute(&command(3 + index as u64, "run.attach", changed)) + .unwrap_err(); + assert!( + error + .to_string() + .contains("requires the same settled ACPX provider profile and session"), + "unexpected thinking identity rejection: {error}" + ); + assert_eq!(fs::read(&state_path).unwrap(), prior); + } + executor + .execute(&command(6, "run.attach", payload)) + .unwrap(); + executor.shutdown().unwrap(); + fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn stops_an_idle_pi_provider_before_suspension_without_waiting_for_its_close_rpc() { + let directory = temporary_directory("pi-idle-suspension"); + let mut config = pi_acpx_config(&directory, "bootstrap"); + config + .acpx_launch_profile + .as_mut() + .unwrap() + .args + .extend(["--suspend-delay-ms".to_owned(), "8000".to_owned()]); + let mut payload = pi_prepare_payload(&directory, "bootstrap"); + payload["provider"]["sidecarArgs"] = json!(config.acpx_launch_profile.as_ref().unwrap().args); + let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config); + executor + .execute(&command(1, "run.prepare", payload)) + .unwrap(); + executor + .execute(&command(2, "session.open", json!({}))) + .unwrap(); + let state_path = directory.join("acpx-provider-state.json"); + let opened: Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + let started = std::time::Instant::now(); + let stopped = executor + .execute(&command(3, "turn.stop", json!({}))) + .unwrap(); + executor + .execute(&command(4, "runner.drain", json!({}))) + .unwrap(); + executor + .execute(&command(5, "runner.suspend", json!({}))) + .unwrap(); + let suspended: Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + let elapsed = started.elapsed(); + executor.shutdown().unwrap(); + fs::remove_dir_all(directory).unwrap(); + + assert_eq!(stopped.result["status"], "stopped"); + assert_eq!(stopped.result["providerExitConfirmed"], true); + assert!( + elapsed < Duration::from_secs(3), + "idle close took {elapsed:?}" + ); + assert_eq!(suspended["lifecycle"], "suspended"); + assert_eq!(suspended["providerExitUnconfirmed"], false); + assert_eq!(suspended["identity"], opened["identity"]); + assert_eq!(suspended["activeProviderTurnId"], Value::Null); +} + +#[test] +fn idle_pi_stop_cannot_publish_a_reusable_identity_while_its_lifetime_fence_is_held() { + let directory = temporary_directory("pi-idle-held-lifetime"); + // Linux can assign port 0 below the identity contract's dynamic-port range. + // Keep this fixture distinct from the fake sidecar's default fence ports. + let fence = (61_000..=u16::MAX) + .filter_map(|port| std::net::TcpListener::bind(("127.0.0.1", port)).ok()) + .take(3) + .collect::>(); + assert_eq!( + fence.len(), + 3, + "three valid lifetime fence ports are required" + ); + let ports = fence + .iter() + .map(|listener| listener.local_addr().unwrap().port().to_string()) + .collect::>() + .join(","); + let mut config = pi_acpx_config(&directory, "bootstrap"); + config + .acpx_launch_profile + .as_mut() + .unwrap() + .args + .extend(["--lifetime-fence-ports".to_owned(), ports]); + let mut payload = pi_prepare_payload(&directory, "bootstrap"); + payload["provider"]["sidecarArgs"] = json!(config.acpx_launch_profile.as_ref().unwrap().args); + let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config); + executor + .execute(&command(1, "run.prepare", payload)) + .unwrap(); + executor + .execute(&command(2, "session.open", json!({}))) + .unwrap(); + let state_path = directory.join("acpx-provider-state.json"); + let opened: Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + let error = executor + .execute(&command(3, "turn.stop", json!({}))) + .unwrap_err(); + assert!(error + .to_string() + .contains("original provider lifetime remains active")); + let stopped: Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + assert_eq!(stopped["lifecycle"], "prepared"); + assert_eq!(stopped["providerExitUnconfirmed"], true); + assert_eq!(stopped["identity"], opened["identity"]); + assert!( + executor + .execute(&command(4, "runner.suspend", json!({}))) + .is_err(), + "unconfirmed lifetime cannot produce a suspension receipt" + ); + let before_poll = fs::read(&state_path).unwrap(); + assert!( + executor.poll_events().unwrap().is_empty(), + "held lifetime cannot publish a resumed provider" + ); + assert_eq!( + fs::read(&state_path).unwrap(), + before_poll, + "polling cannot erase the unconfirmed lifetime boundary" + ); + drop(fence); + executor.shutdown().unwrap(); + fs::remove_dir_all(directory).unwrap(); +} + #[test] fn publishes_only_changed_pi_controls_before_the_new_turn() { for (mode, initially_available, turn_available) in [ diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/process_supervisor.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/process_supervisor.rs index d1fac57753..3bbd49d902 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/process_supervisor.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/process_supervisor.rs @@ -190,6 +190,177 @@ fn verified_launch_preserves_homebrew_node_loader_layout() { process.wait().unwrap(); } +#[cfg(target_os = "macos")] +mod darwin_executable_role { + use super::*; + use std::time::Instant; + + struct OwnedDirectory(PathBuf); + + impl OwnedDirectory { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "paperclip-executable-role-{}", + uuid::Uuid::new_v4().simple() + )); + fs::create_dir(&path).unwrap(); + let mut owned = Self(path); + fs::set_permissions(&owned.0, fs::Permissions::from_mode(0o700)).unwrap(); + owned.0 = fs::canonicalize(&owned.0).unwrap(); + owned + } + } + + impl Drop for OwnedDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn wait_for_success(process: &mut SupervisedProcess) { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if let Some(fact) = process.try_wait().unwrap() { + assert!(fact.success, "verified child must exit successfully"); + return; + } + assert!(Instant::now() < deadline, "verified child did not exit"); + std::thread::sleep(Duration::from_millis(5)); + } + } + + #[test] + fn named_executable_preserves_command_and_script_after_atomic_replacement() { + // Declared first so process/launch guards retire before directory cleanup, + // including assertion failures and unsuccessful process admission. + let directory = OwnedDirectory::new(); + let command = directory.0.join("command"); + let script = directory.0.join("script"); + let original_command = "#!/bin/sh\nprintf '%s\\n' old-command\nprintf '%s\\n' \"$PAPERCLIP_VERIFIED_RUNTIME_EXECUTABLE\"\nexec /bin/sh \"$1\"\n"; + let original_script = "#!/bin/sh\nprintf '%s\\n' old-script\n"; + write_executable(&command, original_command); + write_executable(&script, original_script); + let launch = VerifiedProcessLaunch::new( + VerifiedProcessArtifact::snapshot_verified_executable( + command.clone(), + File::open(&command).unwrap(), + &sha256(original_command), + ) + .unwrap(), + vec![VerifiedProcessArgument::Artifact( + VerifiedProcessArtifact::snapshot_verified( + script.clone(), + File::open(&script).unwrap(), + &sha256(original_script), + ) + .unwrap(), + )], + ) + .with_inherited_runtime_executable(); + let replacement_command = directory.0.join("replacement-command"); + let replacement_script = directory.0.join("replacement-script"); + write_executable( + &replacement_command, + "#!/bin/sh\nprintf '%s\\n' replacement-command\nexec /bin/sh \"$1\"\n", + ); + write_executable( + &replacement_script, + "#!/bin/sh\nprintf '%s\\n' replacement-script\n", + ); + fs::rename(replacement_command, &command).unwrap(); + fs::rename(replacement_script, &script).unwrap(); + let mut process = SupervisedProcess::spawn_verified_with_environment_keys( + &launch, + Duration::from_millis(50), + 1024, + &[], + ) + .unwrap(); + // The live supervised process must retain the named image independently. + drop(launch); + assert_eq!( + process + .receive_stdout_line(Duration::from_secs(1)) + .unwrap() + .as_deref(), + Some("old-command") + ); + let inherited_runtime = process + .receive_stdout_line(Duration::from_secs(1)) + .unwrap() + .unwrap(); + assert_eq!( + Path::new(&inherited_runtime).parent(), + Some(directory.0.as_path()) + ); + assert!(Path::new(&inherited_runtime) + .file_name() + .unwrap() + .to_string_lossy() + .starts_with(".paperclip-verified-executable-")); + assert!(Path::new(&inherited_runtime).exists()); + assert_eq!( + process + .receive_stdout_line(Duration::from_secs(1)) + .unwrap() + .as_deref(), + Some("old-script") + ); + wait_for_success(&mut process); + drop(process); + assert!(!Path::new(&inherited_runtime).exists()); + } + + #[test] + fn named_executable_preserves_homebrew_node_loader_layout() { + // Resolve the same executable PATH would choose without an extra helper + // process; only the supervised Node image is launched by this test. + let node = std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")) + .map(|directory| directory.join("node")) + .find(|path| { + fs::metadata(path).is_ok_and(|metadata| { + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 + }) + }) + .and_then(|path| fs::canonicalize(path).ok()) + .expect("node must resolve on PATH"); + let launch = VerifiedProcessLaunch::new( + VerifiedProcessArtifact::snapshot_verified_executable( + node.clone(), + File::open(&node).unwrap(), + &sha256_file(&node), + ) + .unwrap(), + vec![ + VerifiedProcessArgument::Literal("--eval".to_owned()), + VerifiedProcessArgument::Literal("console.log(process.execPath)".to_owned()), + ], + ); + let mut process = SupervisedProcess::spawn_verified_with_environment_keys( + &launch, + Duration::from_millis(50), + 1024, + &[], + ) + .expect("named verified Node must start with loader-relative libraries"); + drop(launch); + let executed_node = process + .receive_stdout_line(Duration::from_secs(2)) + .unwrap() + .expect("Node must report executable path"); + assert_eq!(Path::new(&executed_node).parent(), node.parent()); + assert!(Path::new(&executed_node) + .file_name() + .unwrap() + .to_string_lossy() + .starts_with(".paperclip-verified-executable-")); + assert!(Path::new(&executed_node).exists()); + wait_for_success(&mut process); + drop(process); + assert!(!Path::new(&executed_node).exists()); + } +} + fn spawn_linger_process() -> (SupervisedProcess, u32, u64) { let harness = PathBuf::from(env!("CARGO_BIN_EXE_fake-harness")); let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs index 00fcfe6fc2..73baa55a9a 100644 --- a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs +++ b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs @@ -1,3 +1,4 @@ +import profiles from "../acpx-profiles.json" with { type: "json" }; import { createHash, timingSafeEqual } from "node:crypto"; import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -7,7 +8,7 @@ import { COPILOT_VERSION, materializePinnedCopilotBinary, resolveCopilotDistribu const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024; const MAX_EXPANDED_BYTES = 384 * 1024 * 1024; -const PROFILE_DIGEST = "sha256:48cecd8dc77a5533240fcf2f29d19be05380da4a79f8e5061480f94241db75a8"; +const PROFILE_DIGEST = profiles.profiles.copilot.commandDigest; /** Build-time only; outputRoot is the exact runner-owned platform asset directory. */ export async function buildPinnedCopilotDistribution({ outputRoot, platform = process.platform, architecture = process.arch }, { fetchImpl = fetch } = {}) { diff --git a/packages/paperclip-runner/scripts/build-node-startup-timeout.test.mjs b/packages/paperclip-runner/scripts/build-node-startup-timeout.test.mjs index 6772e5941a..b79659e95e 100644 --- a/packages/paperclip-runner/scripts/build-node-startup-timeout.test.mjs +++ b/packages/paperclip-runner/scripts/build-node-startup-timeout.test.mjs @@ -11,7 +11,14 @@ test("cold Rosetta Node startup has a bounded build-only allowance", () => { assert.equal(buildNodeStartupTimeout(), buildNodeStartupTimeout(process.platform, process.arch)); }); -test("the ordinary provider pack Node probe uses the bounded startup allowance", () => { +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 858b799c79..b9078a21a8 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -20,7 +20,6 @@ import { dirname, join, relative, resolve } from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs"; -import { writePortableCopilotShims, writePortableExecutableShim } from "./provider-pack-executable-shims.mjs"; import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -167,7 +166,7 @@ try { || !/^sha256:[a-f0-9]{64}$/.test(metadata.profileDigest) || !/^sha256:[a-f0-9]{64}$/.test(metadata.closureDigest)) throw new Error("Candidate builder omitted its pinned identity"); candidateProviders[provider] = { version: metadata.version, profileDigest: metadata.profileDigest, - closureDigest: metadata.closureDigest, qualification: provider === "cursor" ? "qualified" : "pending", path: assetPath, + closureDigest: metadata.closureDigest, qualification: (provider === "cursor" || provider === "pi") ? "qualified" : "pending", path: assetPath, sha256: sha256Tree(join(temporaryRoot, assetPath)) }; } diff --git a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs index 605795b1a8..8630114d88 100644 --- a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs +++ b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs @@ -106,9 +106,36 @@ export async function bundleVerifiedProviderEntrypoints({ write = true } = {}) { } results.push({ entrypoint, result, verifiedResult }); } + await bundlePiProvisioner({ write }); return results; } +/** Explicit setup tooling; no provider payload is included in the npm tarball. */ +export async function bundlePiProvisioner({ write = true, outputRoot = resolve(packageRoot, "dist/cli") } = {}) { + const entrypoint = { name: "provision-pi", source: resolve(packageRoot, "scripts/provision-pi.mjs") }; + const result = await build({ + entryPoints: [entrypoint.source], outfile: resolve(outputRoot, "provision-pi.cjs"), + bundle: true, platform: "node", format: "cjs", target: "node24", packages: "bundle", + splitting: false, sourcemap: false, legalComments: "none", metafile: true, + treeShaking: true, write, logLevel: "silent", + banner: { js: 'const __paperclipVerifiedEntrypointUrl = require("node:url").pathToFileURL(__filename).href;' }, + define: { "import.meta.dirname": "__dirname", "import.meta.url": "__paperclipVerifiedEntrypointUrl" }, + }); + assertSelfContainedBundle(entrypoint, result); + if (write) { + const inputs = resolve(outputRoot, "pi-provision-inputs"); + await mkdir(inputs, { recursive: true }); + for (const [source, name] of [ + ["scripts/pi-distribution/package.json", "package.json"], + ["scripts/pi-distribution/package-lock.json", "package-lock.json"], + ["../../patches/pi-acp@0.0.33.patch", "pi-acp.patch"], + ["src/drivers/acpx/pi-acp-runtime.ts", "pi-acp-runtime.ts"], + ["src/drivers/acpx/pi-runtime-extension.ts", "pi-runtime-extension.ts"], + ]) await cp(resolve(packageRoot, source), resolve(inputs, name)); + } + return result; +} + const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : null; diff --git a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs index 223af55817..fd2c324822 100644 --- a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs +++ b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs @@ -38,3 +38,6 @@ test("written provider entrypoints satisfy qualified launch permissions", async } } }); + +// Package-layout regression runs in the existing verified-entrypoint test lane. +import "./provision-pi-package.test.mjs"; diff --git a/packages/paperclip-runner/scripts/candidate-provider-pack.mjs b/packages/paperclip-runner/scripts/candidate-provider-pack.mjs index 70aee7f19f..cfa2fc1369 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.mjs @@ -1,3 +1,4 @@ +import { materializePiDistribution } from "./materialize-pi-distribution.mjs"; import profiles from "../acpx-profiles.json" with { type: "json" }; import { materializePinnedCursorDistribution } from "./materialize-cursor-distribution.mjs"; const CANDIDATES = new Set(["cursor", "copilot", "pi"]); @@ -5,14 +6,14 @@ const CANDIDATES = new Set(["cursor", "copilot", "pi"]); /** Only materialized providers enter the serialized manifest and its digest. */ export function providerPackManifestFields(providers, candidates) { return { - ...(providers.cursor ? { providers: { cursor: providers.cursor } } : {}), - ...(candidates.length ? { candidateProviders: Object.fromEntries(candidates.map(provider => [provider, providers[provider]])) } : {}), + ...(["pi", "cursor"].some(provider => providers[provider]) ? { providers: Object.fromEntries(["pi", "cursor"].filter(provider => providers[provider]).map(provider => [provider, providers[provider]])) } : {}), + ...(["pi", ...candidates].some(provider => providers[provider]) ? { candidateProviders: Object.fromEntries([...new Set(["pi", ...candidates])].filter(provider => providers[provider]).map(provider => [provider, providers[provider]])) } : {}), }; } export function providerPackProviders(platform, architecture, candidates) { - const cursorSupported = ["darwin-arm64", "darwin-x64", "linux-x64"].includes(`${platform}-${architecture}`); - return [...new Set([...(cursorSupported ? ["cursor"] : []), ...candidates])]; + const nativeSupported = ["darwin-arm64", "darwin-x64", "linux-x64"].includes(`${platform}-${architecture}`); + return [...new Set([...(nativeSupported ? ["pi", "cursor"] : []), ...candidates])]; } export function parseProviderPackArguments(args) { @@ -33,6 +34,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 === "pi") return materializePiDistribution({ outputRoot }); + if (provider === "copilot") { + const { buildPinnedCopilotDistribution } = await import("./build-copilot-distribution.mjs"); + return buildPinnedCopilotDistribution({ outputRoot }); + } if (provider === "cursor") { const result = await materializePinnedCursorDistribution({ destination: outputRoot }); return { version: result.version, diff --git a/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs b/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs index 5a781b011b..c11110baba 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.test.mjs @@ -1,52 +1,28 @@ import test from "node:test"; import assert from "node:assert/strict"; import { parseProviderPackArguments, materializeCandidateProviderPack, providerPackProviders, providerPackManifestFields } from "./candidate-provider-pack.mjs"; -import { createHash } from "node:crypto"; -// Matches the canonical manifest hashing used by the builder and admission. -function digest(value) { - const canonical = value => Array.isArray(value) ? `[${value.map(canonical).join(",")}]` - : value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` - : JSON.stringify(value); - return createHash("sha256").update(canonical(value)).digest("hex"); -} - -test("provider manifest digest survives disk JSON on supported and unsupported targets", () => { - const cursor = { version: "pinned", profileDigest: "sha256:profile", closureDigest: "sha256:closure" }; - for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "x64"], ["linux", "arm64"], ["win32", "x64"]]) { - const selected = providerPackProviders(platform, architecture, []); - const payload = { target: { platform, architecture }, ...providerPackManifestFields(selected.includes("cursor") ? { cursor } : {}, []) }; - const diskPayload = JSON.parse(JSON.stringify(payload)); - assert.deepEqual(diskPayload, payload); - assert.equal(digest(diskPayload), digest(payload)); - assert.equal(diskPayload.providers?.cursor?.version, selected.includes("cursor") ? "pinned" : undefined); - } - const candidate = providerPackManifestFields({ cursor }, ["cursor"]); - assert.deepEqual(candidate.candidateProviders, { cursor }); - assert.equal(digest(JSON.parse(JSON.stringify(candidate))), digest(candidate)); -}); - -test("candidate selection is explicit", () => { +test("candidate options stay explicit and supported native assets are included by default", () => { assert.deepEqual(parseProviderPackArguments(["--", "/pack"]), { output: "/pack", candidates: [] }); assert.deepEqual(parseProviderPackArguments(["/pack", "--candidate-providers=pi,cursor"]), { output: "/pack", candidates: ["pi", "cursor"] }); - assert.deepEqual(providerPackSelections([]), [{ provider: "pi", qualification: "qualified" }]); - assert.deepEqual(providerPackSelections(["pi", "cursor", "copilot"]), [ - { provider: "pi", qualification: "qualified" }, - { provider: "cursor", qualification: "pending" }, - { provider: "copilot", qualification: "pending" }, - ]); - assert.throws(() => providerPackSelections(["other"]), /Unknown/); - assert.throws(() => providerPackSelections(["pi", "pi"]), /duplicate/); -}); -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(platform, architecture, []), ["pi", "cursor"]); + assert.deepEqual(providerPackProviders(platform, architecture, ["cursor", "pi", "copilot"]), ["pi", "cursor", "copilot"]); } assert.deepEqual(providerPackProviders("linux", "arm64", []), []); assert.deepEqual(providerPackProviders("win32", "x64", []), []); assert.deepEqual(providerPackProviders("linux", "arm64", ["cursor"]), ["cursor"]); }); +test("serialized manifests include every materialized qualified provider", () => { + const pi = { qualification: "qualified", profileDigest: "pi-digest" }; + const cursor = { qualification: "qualified", profileDigest: "cursor-digest" }; + const copilot = { qualification: "pending", profileDigest: "copilot-digest" }; + assert.deepEqual(providerPackManifestFields({ pi }, []), { providers: { pi }, candidateProviders: { pi } }); + assert.deepEqual(providerPackManifestFields({ pi, cursor, copilot }, ["copilot"]), { + providers: { pi, cursor }, candidateProviders: { pi, copilot }, + }); + assert.deepEqual(providerPackManifestFields({}, []), {}); +}); 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/generate-acpx-profiles.mjs b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs index 38669bd34e..0329d031c6 100644 --- a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs +++ b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs @@ -39,6 +39,15 @@ const { commandDigest, ...attestation } = contract; assert.equal(commandDigest, `sha256:${createHash("sha256").update(canonicalJson(attestation)).digest("hex")}`); assert.equal(contract.acpxPatchSha256, createHash("sha256") .update(await readFile(new URL("../../patches/acpx@0.13.1.patch", root))).digest("hex")); +for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v20-identity.json"], ["copilot", "test/fixtures/copilot-profile-v16-identity.json"]]) { + const identity = await readJson(path); + assert.equal(manifest.profiles[agent].agentProfileVersion, identity.declaration.agentProfileVersion); + assert.equal(manifest.profiles[agent].commandDigest, identity.commandDigest); + const serialized = agent === "pi" ? canonicalJson(identity.declaration) + : JSON.stringify(Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b)))); + assert.equal(identity.commandDigest, `sha256:${createHash("sha256").update(serialized).digest("hex")}`); + assert.equal(identity.declaration.acpxPatchSha256, contract.acpxPatchSha256); +} for (const distribution of Object.values(distributions.platforms)) assert.match(distribution.closureSha256, /^[a-f0-9]{64}$/); const quote = JSON.stringify; diff --git a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs index d61cfbc073..cfcb25feb1 100644 --- a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs @@ -8,7 +8,7 @@ import { promisify } from "node:util"; import { PI_DISTRIBUTION_CLOSURE_SHA256 } from "../src/drivers/acpx/pi-closure-pins.ts"; import { PI_NODE_DISTRIBUTIONS, PI_NODE_VERSION } from "../src/drivers/acpx/pi-node-pins.ts"; import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs"; -import { QUALIFIED_ACPX_PROFILES } from "../src/drivers/acpx/qualified-profiles.ts"; +import acpxProfiles from "../acpx-profiles.json" with { type: "json" }; import { inventoryPiRuntimeFiles, verifyPiRuntimeManifest } from "../src/drivers/acpx/pi-verified-runtime.ts"; const run = promisify(execFile); @@ -19,8 +19,8 @@ const patchPath = join(workspaceRoot, "patches/pi-acp@0.0.33.patch"); const supportedTargets = new Set(["darwin-arm64", "darwin-x64", "linux-x64"]); export const PI_DISTRIBUTION_PINS = Object.freeze({ wrapper: "0.0.33", runtime: "1.0.0", sdk: "0.26.0", zod: "3.25.76", nodeVersion: PI_NODE_VERSION, undici: "8.10.2", nodeBundledUndici: "7.29.1", - wrapperSha256: "c41750802680543d72a5a43e21eaf91c31ca8cec833bbf5f27330614936810fe", - helperSha256: "2191b1e5f281d24508ec7eaff39011ee863a8a70bf54f4aa0490ad0172b323da", + wrapperSha256: "9d129b3d38772e93e97080aa6c4e574ac5df4e47ce5bc331a484a9fbf8188b36", + helperSha256: "41e0490b617da0d60e0c8ec58ef311236f945129d99f816cff6897f78da7f91d", }); const hash = (bytes) => createHash("sha256").update(bytes).digest("hex"); @@ -109,8 +109,29 @@ export function piDistributionBootstrapSource() { ].join("\n"); } +/** The already hash-verified Node archive also pins setup's package manager. */ +export async function resolvePiBundledNpm(nodeRoot) { + const npmRoot = join(nodeRoot, "lib/node_modules/npm"); + const manifest = join(npmRoot, "package.json"); + const entry = join(npmRoot, "bin/npm-cli.js"); + for (const path of [manifest, entry]) { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || await realpath(path) !== path) throw new Error("Pinned Pi npm has an invalid archive entry"); + } + if (JSON.parse(await readFile(manifest, "utf8")).version !== "11.19.0") throw new Error("Pinned Pi Node archive has an unexpected npm version"); + return entry; +} + /** Build on the target platform. No lifecycle scripts, model requests, or auth. */ -export async function materializePiDistribution({ outputRoot, nodeExecutable, npmExecutable = "npm" }) { +export async function materializePiDistribution({ outputRoot, nodeExecutable, npmExecutable = "npm", inputs, checkCancelled = () => {} }) { + // Cancellation is observed between bounded subprocesses. A setup signal must + // not abandon an npm/tar child while it still owns staging files. + const runOwned = async (...args) => { checkCancelled(); const result = await run(...args); checkCancelled(); return result; }; + checkCancelled(); + const inputLockDirectory = inputs?.lockDirectory ?? lockDirectory; + const inputPatchPath = inputs?.patchPath ?? patchPath; + const helperSourcePath = inputs?.helperSourcePath ?? join(packageRoot, "src/drivers/acpx/pi-acp-runtime.ts"); + const extensionSourcePath = inputs?.extensionSourcePath ?? join(packageRoot, "src/drivers/acpx/pi-runtime-extension.ts"); if (typeof outputRoot !== "string" || !outputRoot || !isAbsolute(outputRoot)) throw new Error("Pi distribution output must be absolute"); const output = resolve(outputRoot); if (["/", workspaceRoot, packageRoot].includes(output)) throw new Error("Refusing unsafe Pi distribution output"); @@ -123,6 +144,7 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np const target = `${process.platform}-${process.arch}`; const nodePin = PI_NODE_DISTRIBUTIONS[target]; let node; + let bundledNpm; if (nodeExecutable) node = await realpath(nodeExecutable); else { const archiveName = `node-v${PI_NODE_VERSION}-${target}.tar.gz`; @@ -137,46 +159,55 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np if (hash(bytes) !== nodePin.archiveSha256) throw new Error("Pi Node archive does not match its release pin"); const archivePath = join(staging, archiveName); await writeFile(archivePath, bytes); const nodeRoot = join(staging, "node-extract"); await mkdir(nodeRoot); - await run("tar", ["-xzf", archivePath, "-C", nodeRoot, "--strip-components=2", `node-v${PI_NODE_VERSION}-${target}/bin/node`], { timeout: 30_000 }); - node = join(nodeRoot, "node"); + await runOwned("tar", ["-xzf", archivePath, "-C", nodeRoot, "--strip-components=1", `node-v${PI_NODE_VERSION}-${target}/bin/node`, `node-v${PI_NODE_VERSION}-${target}/lib/node_modules/npm`], { timeout: 30_000 }); + node = join(nodeRoot, "bin/node"); + bundledNpm = await resolvePiBundledNpm(nodeRoot); } if (hash(await readFile(node)) !== nodePin.executableSha256 || (await lstat(node)).size !== nodePin.executableSize) throw new Error("Pi Node executable does not match its target release pin"); - const version = (await run(node, ["--version"], { env: {}, timeout: buildNodeStartupTimeout() })).stdout.trim(); + const version = (await runOwned(node, ["--version"], { env: {}, timeout: buildNodeStartupTimeout() })).stdout.trim(); if (version !== `v${PI_NODE_VERSION}`) throw new Error("Pi distribution requires exact pinned Node version"); - if ((await run(node, ["-p", "process.versions.undici"], { env: {}, timeout: buildNodeStartupTimeout() })).stdout.trim() !== PI_DISTRIBUTION_PINS.nodeBundledUndici) throw new Error("Pi Node bundled Undici differs from its reviewed security pin"); + if ((await runOwned(node, ["-p", "process.versions.undici"], { env: {}, timeout: buildNodeStartupTimeout() })).stdout.trim() !== PI_DISTRIBUTION_PINS.nodeBundledUndici) throw new Error("Pi Node bundled Undici differs from its reviewed security pin"); await mkdir(runtimeRoot); - await Promise.all(["package.json", "package-lock.json"].map((name) => copyFile(join(lockDirectory, name), join(runtimeRoot, name)))); + await Promise.all(["package.json", "package-lock.json"].map((name) => copyFile(join(inputLockDirectory, name), join(runtimeRoot, name)))); await writeFile(join(runtimeRoot, ".npmrc"), "registry=https://registry.npmjs.org/\nignore-scripts=true\naudit=false\nfund=false\n"); await writeFile(join(runtimeRoot, ".npmrc-global"), ""); const buildHome = join(staging, "build-home"); await mkdir(buildHome); // Do not inherit NPM_TOKEN, npm_config_*, NODE_OPTIONS, provider keys or user // .npmrc. Public registry downloads need no private application credential. - const environment = Object.fromEntries(["PATH", "LANG", "LC_ALL", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"].flatMap((key) => typeof process.env[key] === "string" ? [[key, process.env[key]]] : [])); + const environment = Object.fromEntries(["PATH", "LANG", "LC_ALL", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "https_proxy", "http_proxy", "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"].flatMap((key) => typeof process.env[key] === "string" ? [[key, process.env[key]]] : [])); environment.HOME = buildHome; - await run(npmExecutable, piDistributionInstallCommand(), { cwd: runtimeRoot, env: environment, timeout: 300_000, maxBuffer: 4 * 1024 * 1024 }); + // npm 10 prunes non-host packages bundled by upstream Pi, unlike the npm + // 11.19.0 used to qualify this complete closure. Public setup must use the + // npm pinned by the verified Node archive, never whichever npm is on PATH. + await runOwned(bundledNpm ? node : npmExecutable, [...(bundledNpm ? [bundledNpm] : []), ...piDistributionInstallCommand()], { cwd: runtimeRoot, env: environment, timeout: 300_000, maxBuffer: 4 * 1024 * 1024 }); const installedLockBytes = await readFile(join(runtimeRoot, "package-lock.json")); - if (!installedLockBytes.equals(await readFile(join(lockDirectory, "package-lock.json")))) throw new Error("Pi installation changed its committed lock"); + if (!installedLockBytes.equals(await readFile(join(inputLockDirectory, "package-lock.json")))) throw new Error("Pi installation changed its committed lock"); const lock = JSON.parse(installedLockBytes.toString("utf8")); const packageCount = await verifyLockedPiPackageGraph(runtimeRoot, lock); const wrapper = join(runtimeRoot, "node_modules/pi-acp"); - await run("git", ["apply", "--check", patchPath], { cwd: wrapper, env: environment, timeout: 10_000 }); - await run("git", ["apply", patchPath], { cwd: wrapper, env: environment, timeout: 10_000 }); + await runOwned("git", ["apply", "--check", inputPatchPath], { cwd: wrapper, env: environment, timeout: 10_000 }); + await runOwned("git", ["apply", inputPatchPath], { cwd: wrapper, env: environment, timeout: 10_000 }); const [wrapperBytes, helperBytes, helperSource] = await Promise.all([ readFile(join(wrapper, "dist/index.js")), readFile(join(wrapper, "dist/paperclip-runtime.js")), - readFile(join(packageRoot, "src/drivers/acpx/pi-acp-runtime.ts"), "utf8"), + readFile(helperSourcePath, "utf8"), ]); - const stripped = stripTypeScriptTypes(helperSource).split("\n").map((line) => line.trimEnd()).join("\n"); + // Installed CLI users may run another supported Node patch. Generate the + // exact pinned closure with its verified Node, not the host's TS stripper. + const stripPinnedSource = async (path, source) => inputs + ? (await runOwned(node, ["--input-type=module", "-e", 'import {readFileSync} from "node:fs"; import {stripTypeScriptTypes} from "node:module"; process.stdout.write(stripTypeScriptTypes(readFileSync(process.argv[1],"utf8")));', path], { env: {}, timeout: buildNodeStartupTimeout(), maxBuffer: 4 * 1024 * 1024 })).stdout + : stripTypeScriptTypes(source); + const stripped = (await stripPinnedSource(helperSourcePath, helperSource)).split("\n").map((line) => line.trimEnd()).join("\n"); if (hash(wrapperBytes) !== PI_DISTRIBUTION_PINS.wrapperSha256 || hash(helperBytes) !== PI_DISTRIBUTION_PINS.helperSha256 || helperBytes.toString("utf8") !== stripped) throw new Error("Pi wrapper patch does not match its qualified source"); await mkdir(join(runtimeRoot, "extensions")); - await writeFile(join(runtimeRoot, "extensions/paperclip.js"), stripTypeScriptTypes(await readFile(join(packageRoot, "src/drivers/acpx/pi-runtime-extension.ts"), "utf8")).replace('from "./pi-acp-runtime.js"', 'from "../node_modules/pi-acp/dist/paperclip-runtime.js"')); + await writeFile(join(runtimeRoot, "extensions/paperclip.js"), (await stripPinnedSource(extensionSourcePath, await readFile(extensionSourcePath, "utf8"))).replace('from "./pi-acp-runtime.js"', 'from "../node_modules/pi-acp/dist/paperclip-runtime.js"')); await mkdir(join(runtimeRoot, "node/bin"), { recursive: true }); const copiedNode = join(runtimeRoot, "node/bin/node"); await copyFile(node, copiedNode); await chmod(copiedNode, 0o755); const dependencyListing = process.platform === "darwin" - ? (await run("/usr/bin/otool", ["-L", copiedNode], { env: {}, timeout: 10_000 })).stdout - : (await run("ldd", [copiedNode], { env: { PATH: "/usr/bin:/bin" }, timeout: 10_000 })).stdout; + ? (await runOwned("/usr/bin/otool", ["-L", copiedNode], { env: {}, timeout: 10_000 })).stdout + : (await runOwned("ldd", [copiedNode], { env: { PATH: "/usr/bin:/bin" }, timeout: 10_000 })).stdout; assertPiNodeSystemDependencies(dependencyListing, process.platform); - if ((await run(copiedNode, ["--version"], { env: {}, timeout: buildNodeStartupTimeout() })).stdout.trim() !== version) throw new Error("Copied Pi Node cannot execute after relocation"); + if ((await runOwned(copiedNode, ["--version"], { env: {}, timeout: buildNodeStartupTimeout() })).stdout.trim() !== version) throw new Error("Copied Pi Node cannot execute after relocation"); await rm(buildHome, { recursive: true }); // npm-generated .bin links and hidden lock metadata are not package payload // files. No launch uses PATH; excluding them makes the closure regular-only. @@ -206,23 +237,25 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np runtimeRoot: "runtime", nativeClosureSha256, manifest, }; await writeFile(join(staging, "pi-distribution.json"), `${JSON.stringify(metadata, null, 2)}\n`); + checkCancelled(); await rename(staging, output); const finalRoot = join(output, "runtime"); const binding = await verifyPiRuntimeManifest(finalRoot, manifest); return { version: PI_DISTRIBUTION_PINS.runtime, - profileDigest: QUALIFIED_ACPX_PROFILES.pi.commandDigest, + profileDigest: acpxProfiles.profiles.pi.commandDigest, closureDigest: `sha256:${nativeClosureSha256}`, outputRoot: output, runtimeRoot: finalRoot, manifestPath: join(output, "pi-distribution.json"), metadata, ...binding, }; } catch (error) { await rm(staging, { recursive: true, force: true }); throw error; } } -if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { +if (import.meta.url.endsWith("/materialize-pi-distribution.mjs") && process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { const args = process.argv.slice(2).filter((arg) => arg !== "--"); const outputArgs = args.filter((arg) => !arg.startsWith("--node=")); const nodeArgs = args.filter((arg) => arg.startsWith("--node=")); if (outputArgs.length !== 1 || nodeArgs.length > 1) throw new Error("Usage: node scripts/materialize-pi-distribution.mjs /absolute/output-directory [--node=/absolute/portable-node]"); - const result = await materializePiDistribution({ outputRoot: outputArgs[0], ...(nodeArgs.length ? { nodeExecutable: nodeArgs[0].slice("--node=".length) } : {}) }); - process.stdout.write(`${JSON.stringify({ outputRoot: result.outputRoot, manifestPath: result.manifestPath, manifestDigest: result.manifestDigest, packageCount: result.metadata.packageCount })}\n`); + materializePiDistribution({ outputRoot: outputArgs[0], ...(nodeArgs.length ? { nodeExecutable: nodeArgs[0].slice("--node=".length) } : {}) }).then(result => { + process.stdout.write(`${JSON.stringify({ outputRoot: result.outputRoot, manifestPath: result.manifestPath, manifestDigest: result.manifestDigest, packageCount: result.metadata.packageCount })}\n`); + }).catch(error => { console.error(error.message); process.exitCode = 1; }); } diff --git a/packages/paperclip-runner/scripts/materialize-pi-distribution.test.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.test.mjs index 54e7780cbb..b92c79ca5b 100644 --- a/packages/paperclip-runner/scripts/materialize-pi-distribution.test.mjs +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.test.mjs @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; -import { assertPiNodeSystemDependencies, PI_DISTRIBUTION_PINS, materializePiDistribution, piDistributionInstallCommand, verifyLockedPiPackageGraph, writePiDistributionManifest } from "./materialize-pi-distribution.mjs"; +import { assertPiNodeSystemDependencies, PI_DISTRIBUTION_PINS, materializePiDistribution, resolvePiBundledNpm, piDistributionInstallCommand, verifyLockedPiPackageGraph, writePiDistributionManifest } from "./materialize-pi-distribution.mjs"; import { verifyPiRuntimeManifest } from "../src/drivers/acpx/pi-verified-runtime.ts"; async function fixture(t) { @@ -98,3 +98,19 @@ test("Node dependency inspection rejects Homebrew and non-system Linux libraries assert.throws(() => assertPiNodeSystemDependencies("libnode.so => /opt/lib/libnode.so (0x000)\n", "linux"), /unbundled/); assert.doesNotThrow(() => assertPiNodeSystemDependencies("linux-vdso.so.1 (0x000)\nlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x000)\n/lib64/ld-linux-x86-64.so.2 (0x000)\n", "linux")); }); + + +test("setup selects the exact archive npm and rejects wrong versions or linked entrypoints", async (t) => { + const fixtureRoot = await fixture(t); + const root = await realpath(fixtureRoot.root), write = fixtureRoot.write; + const prefix = "lib/node_modules/npm/"; + await write(prefix + "package.json", JSON.stringify({ name: "npm", version: "11.19.0" })); + await write(prefix + "bin/npm-cli.js", "// pinned archive fixture"); + assert.equal(await resolvePiBundledNpm(root), join(root, prefix, "bin/npm-cli.js")); + await write(prefix + "package.json", JSON.stringify({ name: "npm", version: "10.9.7" })); + await assert.rejects(resolvePiBundledNpm(root), /unexpected npm version/); + await write(prefix + "package.json", JSON.stringify({ name: "npm", version: "11.19.0" })); + await rm(join(root, prefix, "bin/npm-cli.js")); + await symlink(join(root, prefix, "package.json"), join(root, prefix, "bin/npm-cli.js")); + await assert.rejects(resolvePiBundledNpm(root), /invalid archive entry/); +}); diff --git a/packages/paperclip-runner/scripts/provision-pi-package.test.mjs b/packages/paperclip-runner/scripts/provision-pi-package.test.mjs new file mode 100644 index 0000000000..05b7d678ba --- /dev/null +++ b/packages/paperclip-runner/scripts/provision-pi-package.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; +import { build } from "esbuild"; +import { bundlePiProvisioner } from "./build-verified-provider-entrypoints.mjs"; + +test("public server tar layout carries a self-contained host provisioner and exact small inputs", async t => { + const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-public-layout-"))); + t.after(() => rm(root, { recursive: true, force: true })); + const pkg = join(root, "package"); const cli = join(pkg, "dist/vendor/paperclip-runner/cli"); + await mkdir(cli, { recursive: true }); + await writeFile(join(pkg, "package.json"), '{"name":"@paperclipai/server","type":"module"}'); + await writeFile(join(pkg, "dist/index.js"), 'throw new Error("do not start the server");'); + await writeFile(join(cli, "acpx-runtime-sidecar.cjs"), "// layout fixture only"); + await bundlePiProvisioner({ outputRoot: cli }); + const inputs = join(cli, "pi-provision-inputs"); + assert.deepEqual((await readdir(inputs)).sort(), ["package-lock.json", "package.json", "pi-acp-runtime.ts", "pi-acp.patch", "pi-runtime-extension.ts"]); + const packageRoot = resolve(dirname(new URL(import.meta.url).pathname), ".."); + for (const [source, destination] of [ + ["scripts/pi-distribution/package.json", "package.json"], ["scripts/pi-distribution/package-lock.json", "package-lock.json"], + ["../../patches/pi-acp@0.0.33.patch", "pi-acp.patch"], ["src/drivers/acpx/pi-acp-runtime.ts", "pi-acp-runtime.ts"], + ["src/drivers/acpx/pi-runtime-extension.ts", "pi-runtime-extension.ts"], + ]) assert.deepEqual(await readFile(resolve(packageRoot, source)), await readFile(join(inputs, destination))); + // Exercise npm's actual package/ prefix after archive extraction, without + // pretending this small fixture is a complete published Paperclip release. + const archive = join(root, "server.tgz"); + execFileSync("tar", ["-czf", archive, "-C", root, "package"], { env: { PATH: "/usr/bin:/bin" }, timeout: 10_000 }); + const installed = join(root, "installed"); await mkdir(installed); + execFileSync("tar", ["-xzf", archive, "-C", installed], { env: { PATH: "/usr/bin:/bin" }, timeout: 10_000 }); + const installedPackage = join(installed, "package"); const entry = join(installedPackage, "dist/vendor/paperclip-runner/cli/provision-pi.cjs"); + const api = createRequire(import.meta.url)(entry); + const layout = await api.provisionPackageRoot(entry); + assert.equal(layout.root, installedPackage); + const installedRunner = join(installedPackage, "dist/vendor/paperclip-runner"); + assert.equal(layout.assetRoot, installedRunner); + const network = { PATH: "/usr/bin:/bin", HTTPS_PROXY: "http://proxy.example:8080", HTTP_PROXY: "http://proxy.example:8080", NO_PROXY: "localhost", http_proxy: "http://127.0.0.1:9", https_proxy: "http://127.0.0.1:9", no_proxy: "localhost", SSL_CERT_FILE: "/public/ca.pem", SSL_CERT_DIR: "/public/certs", NODE_EXTRA_CA_CERTS: "/public/extra-ca.pem" }; + assert.deepEqual(api.provisionEnvironment({ ...network, HOME: "/private/home", OPENROUTER_API_KEY: "sensitive-canary", NPM_TOKEN: "sensitive-canary", NODE_OPTIONS: "--require /foreign.js", NODE_PATH: "/foreign", NODE_TLS_REJECT_UNAUTHORIZED: "0" }), { ...network, LANG: "C.UTF-8" }); + // Real, unmocked installation verifier rejects a corrupt cache before any + // download/process. The positive full-closure proof uses real platform packs. + await mkdir(join(installedRunner, "provider-assets/pi", `${process.platform}-${process.arch}`, "runtime"), { recursive: true }); + const deny = join(root, "deny.cjs"); + await writeFile(deny, `process.nextTick(()=>{const assert=require('node:assert/strict');assert.equal(process.env.HTTP_PROXY,'http://127.0.0.1:9');assert.equal(process.env.HTTPS_PROXY,'http://127.0.0.1:9');assert.equal(process.env.NO_PROXY,'localhost');assert.equal(process.env.http_proxy,'http://127.0.0.1:9');assert.equal(process.env.https_proxy,'http://127.0.0.1:9');assert.equal(process.env.no_proxy,'localhost');assert.equal(process.env.NODE_EXTRA_CA_CERTS,'/dev/null');for(const key of ['OPENROUTER_API_KEY','NPM_TOKEN','HOME','NODE_OPTIONS','NODE_PATH','NODE_TLS_REJECT_UNAUTHORIZED'])assert.equal(process.env[key],undefined,key);});const fail=()=>{throw Error('UNEXPECTED_NETWORK_OR_CHILD')}; globalThis.fetch=fail; for(const m of ['node:net','node:tls','node:http','node:https']){const x=require(m);for(const k of ['connect','createConnection','request','get'])if(k in x)x[k]=fail;}const c=require('node:child_process');for(const k of ['spawn','execFile','exec'])c[k]=fail;`); + const result = spawnSync(process.execPath, ["--require", deny, entry], { encoding: "utf8", env: { PATH: "/usr/bin:/bin", HTTP_PROXY: "http://127.0.0.1:9", HTTPS_PROXY: "http://127.0.0.1:9", NO_PROXY: "localhost", http_proxy: "http://127.0.0.1:9", https_proxy: "http://127.0.0.1:9", no_proxy: "localhost", NODE_EXTRA_CA_CERTS: "/dev/null", OPENROUTER_API_KEY: "sensitive-canary", NPM_TOKEN: "sensitive-canary", HOME: "/private/home", NODE_OPTIONS: "", NODE_PATH: "/foreign", NODE_TLS_REJECT_UNAUTHORIZED: "0" }, timeout: 10_000 }); + assert.ifError(result.error); assert.equal(result.status, 1); assert.match(result.stderr, /Pi setup failed/); + assert.doesNotMatch(result.stderr, /UNEXPECTED_NETWORK_OR_CHILD|sensitive-canary/); + assert.deepEqual(await readdir(join(installedRunner, "provider-assets/pi")), [`${process.platform}-${process.arch}`]); +}); + +test("explicit CLI setup passes only network settings to its real child and enables Node proxy handling", async t => { + const root = await mkdtemp(join(tmpdir(), "paperclip-pi-setup-environment-")); + t.after(() => rm(root, { recursive: true, force: true })); + const server = join(root, "node_modules/@paperclipai/server"); + const cli = join(server, "dist/vendor/paperclip-runner/cli"); + await mkdir(cli, { recursive: true }); + await writeFile(join(server, "package.json"), JSON.stringify({ name: "@paperclipai/server", type: "module", exports: "./dist/index.js" })); + await writeFile(join(server, "dist/index.js"), "throw Error('server must not start')"); + // A capture child models the public layout only. It cannot download or launch Pi. + await writeFile(join(cli, "provision-pi.cjs"), `const assert=require('node:assert/strict'); + assert.deepEqual(process.execArgv,['--use-env-proxy']); + assert.equal(process.env.HTTP_PROXY,'http://127.0.0.1:9'); + assert.equal(process.env.HTTPS_PROXY,'http://127.0.0.1:9'); + assert.equal(process.env.NO_PROXY,'localhost');assert.equal(process.env.http_proxy,'http://127.0.0.1:9');assert.equal(process.env.https_proxy,'http://127.0.0.1:9');assert.equal(process.env.no_proxy,'localhost'); + assert.equal(process.env.SSL_CERT_FILE,'/public/ca.pem'); + assert.equal(process.env.SSL_CERT_DIR,'/public/certs'); + assert.equal(process.env.NODE_EXTRA_CA_CERTS,'/dev/null'); + for(const key of ['OPENROUTER_API_KEY','NPM_TOKEN','HOME','NODE_OPTIONS','NODE_PATH','NODE_TLS_REJECT_UNAUTHORIZED']) assert.equal(process.env[key],undefined,key); + console.log('SETUP_NETWORK_BOUNDARY_PASS');`); + const modulePath = join(root, "runtime.mjs"); + await build({ entryPoints: [resolve(dirname(new URL(import.meta.url).pathname), "../../../cli/src/commands/runtime.ts")], outfile: modulePath, bundle: true, platform: "node", format: "esm", target: "node24", logLevel: "silent" }); + const result = spawnSync(process.execPath, ["--input-type=module", "-e", "const runtime=await import(process.argv[1]);await runtime.setupPiRuntime();", modulePath], { + encoding: "utf8", timeout: 10_000, env: { PATH: "/usr/bin:/bin", HTTP_PROXY: "http://127.0.0.1:9", HTTPS_PROXY: "http://127.0.0.1:9", NO_PROXY: "localhost", http_proxy: "http://127.0.0.1:9", https_proxy: "http://127.0.0.1:9", no_proxy: "localhost", SSL_CERT_FILE: "/public/ca.pem", SSL_CERT_DIR: "/public/certs", NODE_EXTRA_CA_CERTS: "/dev/null", OPENROUTER_API_KEY: "sensitive-canary", NPM_TOKEN: "sensitive-canary", HOME: "/private/home", NODE_PATH: "/foreign", NODE_TLS_REJECT_UNAUTHORIZED: "0" }, + }); + assert.ifError(result.error); assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /SETUP_NETWORK_BOUNDARY_PASS/); + assert.doesNotMatch(result.stdout + result.stderr, /sensitive-canary/); +}); diff --git a/packages/paperclip-runner/scripts/provision-pi.mjs b/packages/paperclip-runner/scripts/provision-pi.mjs new file mode 100644 index 0000000000..edb68894be --- /dev/null +++ b/packages/paperclip-runner/scripts/provision-pi.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/** Explicit operator setup only. Never imported by npm lifecycle or agent launch. */ +import { constants } from "node:fs"; +import { lstat, mkdir, mkdtemp, open, realpath, readdir, rename, rm, unlink, writeFile } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { materializePiDistribution } from "./materialize-pi-distribution.mjs"; +import { verifyPiInstallation } from "../src/drivers/acpx/pi-installation.ts"; +import { QUALIFIED_ACPX_PROFILES } from "../src/drivers/acpx/qualified-profiles.ts"; +import { PI_DISTRIBUTION_CLOSURE_SHA256 } from "../src/drivers/acpx/pi-closure-pins.ts"; + +export function provisionEnvironment(source = process.env) { + const environment = { PATH: source.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8" }; + for (const key of ["LC_ALL", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "https_proxy", "http_proxy", "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"]) { + if (typeof source[key] === "string") environment[key] = source[key]; + } + return environment; +} + +export async function provisionPackageRoot(entrypoint) { + const canonical = await realpath(entrypoint); + if (canonical !== resolve(entrypoint)) throw new Error("Pi setup entrypoint must not be linked"); + if (basename(canonical) !== "provision-pi.cjs" || !(await lstat(canonical)).isFile()) throw new Error("Pi setup requires its installed entrypoint"); + const cli = dirname(canonical); + const vendored = cli.endsWith("/dist/vendor/paperclip-runner/cli"); + if (!vendored && !cli.endsWith("/dist/cli")) throw new Error("Pi setup requires the installed runner or server layout"); + const root = resolve(cli, vendored ? "../../../.." : "../.."); + const manifest = join(root, "package.json"); + const handle = await open(manifest, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = await handle.stat({ bigint: true }); + if (!before.isFile() || before.size > 65536n || before.nlink !== 1n) throw new Error("Pi setup package manifest is invalid"); + const bytes = await handle.readFile(); const after = await handle.stat({ bigint: true }); const named = await lstat(manifest, { bigint: true }); + if (before.ino !== after.ino || before.dev !== after.dev || before.ctimeNs !== after.ctimeNs || before.mtimeNs !== after.mtimeNs || bytes.length !== Number(before.size) || named.ino !== before.ino || named.dev !== before.dev) throw new Error("Pi setup package manifest changed"); + const expected = vendored ? "@paperclipai/server" : "@paperclipai/paperclip-runner"; + if (JSON.parse(bytes.toString()).name !== expected) throw new Error("Pi setup package identity does not match its layout"); + } finally { await handle.close(); } + return { root, manifest, cli, assetRoot: vendored ? resolve(cli, "..") : root }; +} +async function verifiedInstallation(root, manifest) { + const keys = ["PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST"]; + const previous = keys.map(key => process.env[key]); + process.env[keys[0]] = root; process.env[keys[1]] = manifest; + try { const installation = await verifyPiInstallation(QUALIFIED_ACPX_PROFILES.pi); const lease = await installation.openCommand(); await lease.close(); } + finally { keys.forEach((key, index) => { if (previous[index] === undefined) delete process.env[key]; else process.env[key] = previous[index]; }); } +} +async function absent(path) { try { await lstat(path); return false; } catch (error) { if (error.code === "ENOENT") return true; throw error; } } +async function containedDirectory(root, path) { + await mkdir(path, { recursive: true, mode: 0o700 }); + if (await realpath(path) !== path || !(await lstat(path)).isDirectory() || !path.startsWith(root + "/")) throw new Error("Pi setup asset directory escapes its package"); +} +export async function provisionPi(entrypoint, checkCancelled = () => {}) { + checkCancelled(); + const target = `${process.platform}-${process.arch}`; + if (!Object.hasOwn(PI_DISTRIBUTION_CLOSURE_SHA256, target)) throw new Error(`Pi is not qualified for platform ${target}`); + const { root, manifest, cli, assetRoot } = await provisionPackageRoot(entrypoint); + const assets = join(assetRoot, "provider-assets"); const parent = join(assets, "pi"); + await containedDirectory(root, assets); await containedDirectory(root, parent); + const lockPath = join(parent, `.setup-${target}.lock`); + const lock = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); + let lockIdentity; let temporary; let temporaryIdentity; let published; let committed = false; + const output = join(parent, target); + try { + lockIdentity = await lock.stat({ bigint: true }); + checkCancelled(); + if (!(await absent(output))) { + await verifiedInstallation(root, manifest); + return { status: "verified_existing", target, profileDigest: QUALIFIED_ACPX_PROFILES.pi.commandDigest }; + } + temporary = await mkdtemp(join(parent, ".setup-private-")); + temporaryIdentity = await lstat(temporary, { bigint: true }); + const stagingRoot = join(temporary, "package"); await mkdir(stagingRoot, { mode: 0o700 }); + await writeFile(join(stagingRoot, "package.json"), JSON.stringify({ name: "@paperclipai/paperclip-runner" }), { flag: "wx", mode: 0o600 }); + const stagedOutput = join(stagingRoot, "provider-assets/pi", target); + const inputs = join(cli, "pi-provision-inputs"); + await materializePiDistribution({ outputRoot: stagedOutput, checkCancelled, inputs: { + lockDirectory: inputs, patchPath: join(inputs, "pi-acp.patch"), + helperSourcePath: join(inputs, "pi-acp-runtime.ts"), extensionSourcePath: join(inputs, "pi-runtime-extension.ts"), + } }); + await verifiedInstallation(stagingRoot, join(stagingRoot, "package.json")); + if (!(await absent(output))) throw new Error("Pi setup destination appeared during installation; refusing replacement"); + checkCancelled(); + // mkdir is exclusive: rename(directory) would replace an empty concurrent + // destination. Claim a private final root instead; readiness fails closed + // until every entry is installed and the complete closure verifies. + await mkdir(output, { mode: 0o700 }); + published = await lstat(output, { bigint: true }); + for (const name of await readdir(stagedOutput)) { + const named = await lstat(output, { bigint: true }); + if (named.dev !== published.dev || named.ino !== published.ino || named.isSymbolicLink()) throw new Error("Pi setup output ownership changed during publication"); + await rename(join(stagedOutput, name), join(output, name)); + } + await verifiedInstallation(root, manifest); + checkCancelled(); + committed = true; + return { status: "installed_verified", target, profileDigest: QUALIFIED_ACPX_PROFILES.pi.commandDigest }; + } finally { + // Drain every cleanup even if one fails. Never remove a pre-existing or + // replaced destination or another invocation's lock. + const cleanup = []; + if (published && !committed) cleanup.push((async () => { + const named = await lstat(output, { bigint: true }); + if (named.dev !== published.dev || named.ino !== published.ino || named.isSymbolicLink()) throw new Error("Pi setup output ownership changed; refusing cleanup"); + await rm(output, { recursive: true }); + })()); + if (temporary) cleanup.push((async () => { + const named = await lstat(temporary, { bigint: true }); + if (!temporaryIdentity || named.dev !== temporaryIdentity.dev || named.ino !== temporaryIdentity.ino || named.isSymbolicLink()) throw new Error("Pi setup staging ownership changed; refusing cleanup"); + await rm(temporary, { recursive: true }); + })()); + cleanup.push((async () => { + try { + lockIdentity ??= await lock.stat({ bigint: true }); + const named = await lstat(lockPath, { bigint: true }); + if (named.dev !== lockIdentity.dev || named.ino !== lockIdentity.ino) throw new Error("Pi setup lock ownership changed; refusing cleanup"); + await unlink(lockPath); + } finally { await lock.close(); } + })()); + const results = await Promise.allSettled(cleanup); + const failures = results.filter(result => result.status === "rejected"); + if (failures.length) throw new AggregateError(failures.map(result => result.reason), "Pi setup cleanup failed; inspect the package before retrying"); + } +} +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + const args = process.argv.slice(2); + if (args.length) throw new Error("Usage: paperclipai runtime setup pi (explicit host-platform installation; no model calls)"); + // Preserve the same closed network allowlist as the explicit CLI command. + // Never forward provider keys, HOME config, NODE_OPTIONS or npm configuration. + const environment = provisionEnvironment(); + for (const key of Object.keys(process.env)) delete process.env[key]; Object.assign(process.env, environment); + let cancelled = false; + const cancel = () => { cancelled = true; }; + process.on("SIGINT", cancel); process.on("SIGTERM", cancel); + // Each active materializer subprocess has its original <=300s timeout. A + // cancellation waits for that owned child before removing its files. + const deadline = setTimeout(cancel, 900_000); deadline.unref(); + provisionPi(fileURLToPath(import.meta.url), () => { if (cancelled) throw new Error("Pi setup cancelled; no installation was admitted"); }).finally(() => { + clearTimeout(deadline); process.off("SIGINT", cancel); process.off("SIGTERM", cancel); + }).then(value => console.log(JSON.stringify(value))) + .catch(error => { console.error(`Pi setup failed: ${error.message}`); process.exitCode = 1; }); +} diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.mjs index d75f4bbb6d..48643ea708 100644 --- a/packages/paperclip-runner/scripts/stage-runner-binary.mjs +++ b/packages/paperclip-runner/scripts/stage-runner-binary.mjs @@ -1,24 +1,38 @@ import { execFile } from "node:child_process"; -import { chmod, copyFile, mkdir } from "node:fs/promises"; +import { constants } from "node:fs"; +import { chmod, copyFile, mkdir, mkdtemp, rename, rm } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; const execFileAsync = promisify(execFile); -const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const executable = process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; -const source = path.join(packageRoot, "runner", "target", "release", executable); -const destinationDirectory = path.join(packageRoot, "dist", "bin"); -const destination = path.join(destinationDirectory, executable); +export async function stageRunnerBinary(source, destination) { + const destinationDirectory = path.dirname(destination); + await mkdir(destinationDirectory, { recursive: true }); + const stagingDirectory = await mkdtemp(path.join(destinationDirectory, ".runnerd-stage-")); + const staged = path.join(stagingDirectory, path.basename(destination)); + try { + await copyFile(source, staged, constants.COPYFILE_EXCL); + if (process.platform !== "win32") await chmod(staged, 0o755); + // Sign the new inode before publication. Replacing the pathname atomically + // preserves a running old executable instead of truncating its live inode. + if (process.platform === "darwin") { + await execFileAsync("codesign", ["--force", "--sign", "-", staged], { timeout: 30_000 }); + await execFileAsync("codesign", ["--verify", "--strict", staged], { timeout: 30_000 }); + } + await rename(staged, destination); + } finally { + // This fresh directory is the only cleanup target, never the destination. + await rm(stagingDirectory, { recursive: true, force: true }); + } +} -await mkdir(destinationDirectory, { recursive: true }); -await copyFile(source, destination); -if (process.platform !== "win32") await chmod(destination, 0o755); -// Rust's linker emits an ad-hoc Mach-O signature. Copying that executable to -// its package location preserves the bytes but can leave the kernel rejecting -// the new inode with SIGKILL. Re-sign the staged inode so local packaged-runner -// evals execute the same artifact that was just built. -if (process.platform === "darwin") { - await execFileAsync("codesign", ["--force", "--sign", "-", destination]); +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const executable = process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; + await stageRunnerBinary( + path.join(packageRoot, "runner", "target", "release", executable), + path.join(packageRoot, "dist", "bin", executable), + ); } diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs new file mode 100644 index 0000000000..81696b8ba6 --- /dev/null +++ b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { stageRunnerBinary } from "./stage-runner-binary.mjs"; + +async function withOwnedChildren(root, action) { + const owned = []; + const start = (command, args) => { + const child = spawn(command, args, { env: { PATH: "/usr/bin:/bin" }, stdio: "ignore" }); + // close follows both exit and spawn error. A failed second spawn must not + // interrupt retirement of the first still-running executable. + const closed = new Promise(resolve => child.once("close", resolve)); + const spawned = once(child, "spawn"); + void spawned.catch(() => {}); + owned.push({ child, closed }); + return { child, spawned, closed }; + }; + try { return await action(start); } + finally { + try { + const results = await Promise.allSettled(owned.map(async ({ child, closed }) => { + if (child.pid && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + await closed; + })); + const failures = results.filter(result => result.status === "rejected"); + if (failures.length) throw new AggregateError(failures.map(result => result.reason), "Owned test child cleanup failed"); + } finally { await rm(root, { recursive: true, force: true }); } + } +} + +test("atomically publishes while the previous executable is running", { skip: process.platform === "win32", timeout: 15_000 }, async () => { + const root = await mkdtemp(path.join(tmpdir(), "runnerd-stage-test-")); + const destination = path.join(root, "paperclip-runnerd"); + await withOwnedChildren(root, async start => { + await stageRunnerBinary("/bin/sleep", destination); + const previous = await stat(destination); + const old = start(destination, ["30"]); + await old.spawned; + await stageRunnerBinary("/bin/sleep", destination); + const published = await stat(destination); + assert.notEqual(published.ino, previous.ino); + assert.equal(published.mode & 0o777, 0o755); + assert.equal(old.child.exitCode, null); + assert.equal(old.child.signalCode, null); + assert.deepEqual(await readdir(root), ["paperclip-runnerd"]); + const next = start(destination, ["0"]); + await next.spawned; await next.closed; + assert.equal(next.child.exitCode, 0); + }); +}); + +test("a second spawn error still retires the live old child and removes its fixture", { skip: process.platform === "win32", timeout: 15_000 }, async () => { + const root = await mkdtemp(path.join(tmpdir(), "runnerd-stage-spawn-failure-")); + let old; + await assert.rejects(withOwnedChildren(root, async start => { + old = start("/bin/sleep", ["30"]); await old.spawned; + const failed = start(path.join(root, "missing-executable"), []); + await failed.spawned; + }), { code: "ENOENT" }); + assert.notEqual(old.child.signalCode, null); + await assert.rejects(stat(root), { code: "ENOENT" }); +}); + +test("failed preparation preserves the published bytes and mode", async () => { + const root = await mkdtemp(path.join(tmpdir(), "runnerd-stage-failure-")); + const destination = path.join(root, "paperclip-runnerd"); + try { + await writeFile(destination, "previous build", { mode: 0o755 }); + const before = await stat(destination); + await assert.rejects(stageRunnerBinary(path.join(root, "absent-source"), destination), { code: "ENOENT" }); + assert.equal(await readFile(destination, "utf8"), "previous build"); + const after = await stat(destination); + assert.equal(after.ino, before.ino); + assert.equal(after.mode, before.mode); + assert.deepEqual(await readdir(root), ["paperclip-runnerd"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); 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 03180671a2..a77e410026 100644 --- a/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts @@ -1,3 +1,4 @@ +import { resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js"; import type { NativeExecutionInput } from "../contracts/native-execution.js"; import type { NativeSessionBackend } from "../contracts/native-session-backend.js"; import { @@ -14,7 +15,7 @@ import { export interface CodexAcpxNativeSessionBackendOptions extends Omit< CodexAcpxDriverOptions, - "model" | "permissionMode" | "mode" | "systemInstructions" | "providerPolicy" | "runtimeContext" + "model" | "permissionMode" | "mode" | "piThinkingLevel" | "systemInstructions" | "providerPolicy" | "runtimeContext" > {} export type AcpxNativeSessionBackendOptions = @@ -58,6 +59,7 @@ export function createAcpxNativeSessionBackend( throw new Error("ACPX candidate direct execution requires completed qualification; use the host-controlled runnerd evaluation path"); } + resolvePiThinkingLevel(input.provider.agent, input.provider.piThinkingLevel); const constraints = nativeTaskConstraints(input); const systemInstructions = [ nativeSystemInstructions(input), @@ -73,6 +75,7 @@ export function createAcpxNativeSessionBackend( model: input.provider.model, permissionMode: input.provider.permissionMode ?? "approve-reads", mode: input.provider.mode, + piThinkingLevel: input.provider.piThinkingLevel, 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.test.ts b/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts index 82ecaacc23..e1542606a2 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts @@ -851,7 +851,7 @@ describe("HarnessDriverBackend", () => { expect(events.map((event) => event.eventType)).toEqual([ "runtime_request.created", "runtime_request.expired", - "turn.interrupted", + "turn.failed", ]); } await session.close({ reason: "fixture complete" }); @@ -898,7 +898,7 @@ describe("HarnessDriverBackend", () => { await session.close({ reason: "fixture complete" }); }); - it("emits one non-replayable input expiration and terminal wait after provider loss", async () => { + it("expires pending input without converting provider loss into a successful wait", async () => { const questionSet = { schema: "paperclip.question_set.v1" as const, questions: [{ id: "target", prompt: "Which target?", required: true, answerMode: "text" as const }], @@ -940,11 +940,14 @@ describe("HarnessDriverBackend", () => { }, } }); await expect(iterator.next()).resolves.toMatchObject({ value: { - eventType: "turn.interrupted", + eventType: "turn.failed", sourceSeq: 3, payload: { reason: "provider_process_lost" }, } }); await expect(iterator.next()).resolves.toMatchObject({ done: true }); + await expect(session.snapshot()).resolves.toMatchObject({ terminal: { + turnTerminalState: "failed", runTerminalState: "failed", + } }); }); it("does not synthesize a fallback after the input was already resolved", async () => { diff --git a/packages/paperclip-runner/src/backends/harness-driver-backend.ts b/packages/paperclip-runner/src/backends/harness-driver-backend.ts index 7c265695fb..6e3897e4e5 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.ts @@ -507,7 +507,7 @@ class HarnessNativeSession implements NativeSession { let sourceInstanceId: string | null = null; let lastSourceSequence = 0; let sawTerminal = false; - let synthesizedDurableWait = false; + let synthesizedProviderFailure = false; let streamFailure: unknown = null; const observedPendingInputs = new Map>(); try { @@ -630,7 +630,7 @@ class HarnessNativeSession implements NativeSession { this.#rethrowProtocolIntegrity(error); return null; }); - let governedWaitTurnId: string | undefined; + let providerLossTurnId: string | undefined; for (const request of observedPendingInputs.values()) { const sourceSeq = Math.max(lastSourceSequence, snapshot?.lastSourceSequence ?? 0) + 1; @@ -638,7 +638,7 @@ class HarnessNativeSession implements NativeSession { const requestId = String(request.requestId); const turnId = typeof request.turnId === "string" ? request.turnId : undefined; - governedWaitTurnId ??= turnId; + providerLossTurnId ??= turnId; const itemId = typeof request.itemId === "string" ? request.itemId : requestId; yield { @@ -670,16 +670,18 @@ class HarnessNativeSession implements NativeSession { }, }; } - if (governedWaitTurnId) { + if (providerLossTurnId) { + // Expiring input preserves its durable human fallback. The unexpected + // transport loss remains a failed provider execution, never a yield. const sourceSeq = Math.max(lastSourceSequence, snapshot?.lastSourceSequence ?? 0) + 1; lastSourceSequence = sourceSeq; - synthesizedDurableWait = true; + synthesizedProviderFailure = true; sawTerminal = true; this.#terminal = { schema: "paperclip.prp.terminal.v1", - turnTerminalState: "interrupted", - runTerminalState: "cancelled", + turnTerminalState: "failed", + runTerminalState: "failed", reportedWorkDisposition: "yielded", }; yield { @@ -690,16 +692,16 @@ class HarnessNativeSession implements NativeSession { sourceKind: "runner", runId: this.#input.identity.runId, normalizedSessionId: this.#input.identity.sessionId, - turnId: governedWaitTurnId, - eventType: "turn.interrupted", + turnId: providerLossTurnId, + eventType: "turn.failed", schemaVersion: 1, priority: 0, emittedAt: new Date().toISOString(), - payload: { status: "interrupted", reason: "provider_process_lost" }, + payload: { status: "failed", reason: "provider_process_lost" }, }; } } - if (streamFailure && !synthesizedDurableWait) throw streamFailure; + if (streamFailure && !synthesizedProviderFailure) throw streamFailure; } async startTurn(input: Parameters[0]) { diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index 47d588ce6a..6c5a80780f 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -1,5 +1,5 @@ -import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js"; -import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { QUALIFIED_ACPX_PROFILES, resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js"; +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -519,7 +519,15 @@ describe("native backend factory", () => { }, ); - it.each(["pi", "copilot"] as const)("rejects unqualified %s direct execution even with an exact persisted profile", agent => { + it("keeps direct Pi execution held in the prerequisite layer", async () => { + const input = acpxExecution(); + if (input.provider.kind !== "acpx") throw new Error("invalid fixture"); + const profile = resolveQualifiedAcpxProfile("pi", "custom/explicit-test-model"); + Object.assign(input.provider, { agent: "pi", model: profile.qualificationModel, piThinkingLevel: "low", profile }); + expect(() => createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime" })).toThrow(/candidate direct execution requires completed qualification/); + }); + + it.each(["copilot"] as const)("rejects unqualified %s direct execution even with an exact persisted profile", agent => { const input = acpxExecution(); if (input.provider.kind !== "acpx") throw new Error("invalid fixture"); const model = "explicit-fixture-model"; @@ -530,13 +538,39 @@ describe("native backend factory", () => { })).toThrow("ACPX candidate direct execution requires completed qualification"); }); - it("constructs the qualified Cursor backend without candidate admission", async () => { + it.each([ + ["cursor", "../../test/fixtures/cursor-acp/profile-v7-identity.json"], + ["cursor", "../../test/fixtures/cursor-acp/profile-v10-identity.json"], + ["copilot", "../../test/fixtures/copilot-profile-v14-identity.json"], + ["pi", "../../test-fixtures/pi-acp/profile-v13-identity.json"], + ["pi", "../../test-fixtures/pi-acp/profile-v14-identity.json"], + ["pi", "../../test-fixtures/pi-acp/profile-v18-identity.json"], + ["cursor", "../../test/fixtures/cursor-acp/profile-v9-identity.json"], + ["copilot", "../../test/fixtures/copilot-profile-v7-identity.json"], + ["copilot", "../../test/fixtures/copilot-profile-v9-identity.json"], + ["copilot", "../../test/fixtures/copilot-profile-v10-identity.json"], + ["copilot", "../../test/fixtures/copilot-profile-v11-identity.json"], + ["pi", "../../test-fixtures/pi-acp/profile-v9-identity.json"], + ] as const)("rejects the exact historical %s identity before runtime startup", (agent, path) => { + const historical = JSON.parse(readFileSync(new URL(path, import.meta.url), "utf8")); const input = acpxExecution(); if (input.provider.kind !== "acpx") throw new Error("invalid fixture"); - const model = "explicit-cursor-model"; - Object.assign(input.provider, { agent: "cursor", model, mode: "agent", profile: resolveQualifiedAcpxProfile("cursor", model) }); - const backend = createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime", acpxEnvironment: { CURSOR_API_KEY: "explicit-fixture" } }); - await expect(backend.descriptor()).resolves.toMatchObject({ name: "acpx_runtime", version: "0.13.1" }); + const current = resolveQualifiedAcpxProfile(agent, agent === "pi" ? "custom/explicit-test-model" : "explicit-fixture-model"); + Object.assign(input.provider, { agent, model: current.qualificationModel, profile: { ...current, + agentProfileVersion: historical.declaration.agentProfileVersion, commandDigest: historical.commandDigest } }); + expect(() => createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime" })) + .toThrow("does not match the qualified agentProfileVersion"); + input.provider.profile.agentProfileVersion = current.agentProfileVersion; + expect(() => createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime" })) + .toThrow("does not match the qualified commandDigest"); + }); + + it.each([1, 2] as const)("rejects a Pi version %s warm snapshot after the rich ACP upgrade", version => { + const input = acpxExecution("pi"); + if (input.provider.kind !== "acpx") throw new Error("invalid fixture"); + input.provider.profile.agentProfileVersion = version; + expect(() => createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime" })) + .toThrow("does not match the qualified agentProfileVersion"); }); it("rejects a Codex ACPX snapshot that drifts from its qualified profile", () => { 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 a16efaf25d..53a6898d91 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -1,3 +1,4 @@ +import { acpxProfileActivity } from "../drivers/acpx/profile-activity.js"; import { appendSemanticToolReceipt, readNativeSemanticReceipt, semanticInputSha256 } from "../drivers/semantic-tool-receipt.js"; import { startRunnerToolBridge, type RunnerToolCall } from "../drivers/runner-tool-bridge.js"; import { validatePrpStructuredRunResult } from "../protocol/replay-contract.js"; @@ -11,11 +12,52 @@ import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; +describe("live sidecar pending request snapshots", () => { + it("attests current callback identities after the live status barrier without exposing forms", async () => { + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const start = source.indexOf(' if (request.command === "session.snapshot") {'); + const end = source.indexOf(' if (request.command === "session.goal.get") {', start); + expect(start).toBeGreaterThan(0); + const inputs = new Map([['input-1', { turnId: 'turn-1', questionSet: { private: 'hidden form' } }]]); + const permissions = new Map([['permission-1', { turnId: 'turn-1', normalized: { private: 'hidden permission' } }]]); + const host = { identity: () => 'identity', binding: () => 'binding' }; + const readStatus = vi.fn(async () => { + inputs.delete('input-1'); + inputs.set('input-2', { turnId: 'turn-1', questionSet: { private: 'new hidden form' } }); + return { live: true }; + }); + const run = new Function('requireHost', 'readSidecarHostStatusWithin', 'sanitizeRuntimeStatus', 'acpxProviderSessionIdentity', 'inputs', 'permissions', ` + const request={command:"session.snapshot"}, tools=new Map(), runId="run-1", turnId="turn-1", sequence=7; + ${stripTypeScriptTypes(`async function readSnapshot() { ${source.slice(start, end)} }`)} + return readSnapshot; + `)(() => host, readStatus, (value: unknown) => value, () => ({ kind: 'acpx' }), inputs, permissions); + const snapshot = await run(); + expect(readStatus).toHaveBeenCalledExactlyOnceWith(host); + expect(snapshot).toMatchObject({ runId: 'run-1', turnId: 'turn-1', pendingRuntimeRequests: [ + { requestId: 'input-2', type: 'input', turnId: 'turn-1' }, + { requestId: 'permission-1', type: 'permission', turnId: 'turn-1' }, + ] }); + expect(JSON.stringify(snapshot)).not.toContain('hidden'); + }); + + it("rejects the snapshot when its provider status cannot be verified", async () => { + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const start = source.indexOf(' if (request.command === "session.snapshot") {'); + const end = source.indexOf(' if (request.command === "session.goal.get") {', start); + const run = new Function('requireHost', 'readSidecarHostStatusWithin', 'sanitizeRuntimeStatus', ` + const request={command:"session.snapshot"}; + ${stripTypeScriptTypes(`async function readSnapshot() { ${source.slice(start, end)} }`)} + return readSnapshot; + `)(() => ({}), async () => { throw new Error('provider process lost'); }, (value: unknown) => value); + await expect(run()).rejects.toThrow('provider process lost'); + }); +}); + import { deliverAcpxResponse } from "../drivers/acpx/response-delivery.js"; 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, stringifyAcpxSidecarFrame } from "../drivers/acpx/sidecar-protocol.js"; +import { ACPX_SIDECAR_PROTOCOL_VERSION, ACPX_SIDECAR_MAX_FRAME_BYTES, stringifyAcpxSidecarFrame, parseAcpxSidecarRequest, record, text } from "../drivers/acpx/sidecar-protocol.js"; import { canonicalProviderEventsFromAcpxRuntimeEvent } from "../provider-events.js"; import { createPiMessageProjection } from "../drivers/acpx/pi-message-projection.js"; import { createCopilotToolEvidence } from "../drivers/acpx/copilot-tool-evidence.js"; @@ -53,10 +95,10 @@ describe("qualified ACPX runtime sidecar", () => { const end = source.indexOf(" const usage = persistedAcpxTurnUsage(", start); expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); const emitted: unknown[] = []; - const project = new Function("persistedCursorUsageNotice", "validateAcpxRichEvent", "emit", "usageBefore", "usageAfter", "agent", ` - const currentTurnId="turn", runtimeTurn={requestId:"request-1"}, openParams={agent}; + const project = new Function("acpxProfileActivity", "validateAcpxRichEvent", "emit", "usageBefore", "usageAfter", "agent", ` + const currentTurnId="turn", runtimeTurn={requestId:"request-1"}, openParams={agent}, activity=acpxProfileActivity(agent); ${stripTypeScriptTypes(source.slice(start, end))} - `).bind(null, persistedCursorUsageNotice, validateAcpxRichEvent, (...args: unknown[]) => emitted.push(args)); + `).bind(null, acpxProfileActivity, validateAcpxRichEvent, (...args: unknown[]) => emitted.push(args)); const before = { promptMessageIds: [], requestTokenUsage: {} }; const after = { lastRequestId: "request-1", promptMessageIds: ["prompt-1"], requestTokenUsage: {}, cursorPromptUsage: { request_id: "request-1", prompt_message_id: "prompt-1", receipt: { @@ -82,7 +124,7 @@ describe("qualified ACPX runtime sidecar", () => { const after = { lastRequestId: "request-1", requestTokenUsage: { "prompt-1": { input_tokens: 12, output_tokens: 3 } } }; const project = new Function("readSidecarHostStatusWithin", "persistedCursorUsageNotice", "persistedAcpxTurnUsage", "acpxUsageEstimateNotice", "validateAcpxRichEvent", "emit", "diagnostic", ` return (async () => { - const activeHost={}, currentTurnId="turn", runtimeTurn={requestId:"request-1"}, openParams={agent:"cursor"}; + const activeHost={}, currentTurnId="turn", runtimeTurn={requestId:"request-1"}, openParams={agent:"cursor"}, activity={usageNotice:persistedCursorUsageNotice}; const usageBefore={requestTokenUsage:{}}, sanitizeRuntimeEvent=value=>value, safeMessage=()=>"fixture error"; ${stripTypeScriptTypes(source.slice(start, end))} })(); @@ -104,7 +146,7 @@ describe("qualified ACPX runtime sidecar", () => { it.each(["codex", "claude", "grok", "pi", "copilot", null])("preserves existing non-Cursor sidecar identity policy: %s", agent => { const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); const start = source.indexOf("function stableProviderIdentity("); - const stable = new Function("createHash", "cursorToolIdentity", "openParams", `${stripTypeScriptTypes(source.slice(start, source.indexOf("\nfunction canonicalJson", start)))}; return stableProviderIdentity;`)(createHash, cursorToolIdentity, agent ? { agent } : null); + const stable = new Function("createHash", "acpxProfileActivity", "openParams", "initializedAgent", `${stripTypeScriptTypes(source.slice(start, source.indexOf("\nfunction canonicalJson", start)))}; return stableProviderIdentity;`)(createHash, acpxProfileActivity, agent ? { agent } : null, null); for (const kind of ["tool", "message"]) { for (const raw of ["safe-tool", "tool/1", "native\u0080tool", "native\u0085tool", "native\u009ftool", "x".repeat(161)]) { expect(stable(raw, kind)).toBe(raw); @@ -118,7 +160,7 @@ describe("qualified ACPX runtime sidecar", () => { it.each(["tool-first", "permission-first"])("uses the same Cursor identity in actual sidecar tool and pending permission paths: %s", async order => { const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); const identityStart = source.indexOf("function stableProviderIdentity("); - const stableIdentity = new Function("createHash", "cursorToolIdentity", "openParams", `${stripTypeScriptTypes(source.slice(identityStart, source.indexOf("\nfunction canonicalJson", identityStart)))}; return stableProviderIdentity;`)(createHash, cursorToolIdentity, { agent: "cursor" }); + const stableIdentity = new Function("createHash", "acpxProfileActivity", "openParams", "initializedAgent", `${stripTypeScriptTypes(source.slice(identityStart, source.indexOf("\nfunction canonicalJson", identityStart)))}; return stableProviderIdentity;`)(createHash, acpxProfileActivity, { agent: "cursor" }, "cursor"); const boundStart = source.indexOf("function boundRuntimeEventForNormalization("); const bound = new Function("boundedOptionalText", "stableProviderIdentity", "safeAcpxLocations", "openParams", "safeOutput", `${stripTypeScriptTypes(source.slice(boundStart, source.indexOf("\nfunction sanitizeRuntimeEvent", boundStart)))}; return boundRuntimeEventForNormalization;`)( @@ -175,13 +217,13 @@ describe("qualified ACPX runtime sidecar", () => { const end = source.indexOf("\nfunction elicitationResponse(", start); const code = stripTypeScriptTypes(source.slice(start, end)); const emitted: any[] = [], inputs = new Map(); - const invoke = new Function("cursorPlanToolIdentity", "requireAcpxResponseDelivery", "emit", "inputs", ` + const invoke = new Function("acpxProfileActivity", "requireAcpxResponseDelivery", "emit", "inputs", ` const turnId="turn", openParams={agent:"cursor"}, initializedAgent="cursor", MAX_PENDING_INPUTS=16; let requestSequence=0; const stableRequestId=()=>"input-request"; ${code} return waitForExtensionInput; - `)(cursorPlanToolIdentity, (context: any) => context.responseDelivery, (...args: any[]) => emitted.push(args), inputs); + `)(acpxProfileActivity, (context: any) => context.responseDelivery, (...args: any[]) => emitted.push(args), inputs); const abort = new AbortController(); const pending = invoke("turn", { method: "cursor/create_plan", details: { toolCallId: "tool with spaces" }, questionSet: { schema: "paperclip.question_set.v1", questions: [] }, cancel: () => ({ cancelled: true }) }, { requestId: 0, signal: abort.signal, responseDelivery: Promise.resolve() }); expect(emitted).toEqual([["runtime.input_requested", expect.objectContaining({ toolCallId: "tool with spaces", origin: { adapter: "acpx-runtime-sidecar", provider: "cursor", method: "cursor/create_plan" } }), "turn"]]); @@ -190,18 +232,18 @@ describe("qualified ACPX runtime sidecar", () => { }); it.each(["cursor", "copilot", "pi"])("binds native tool evidence to the active sidecar turn for %s", agent => { const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); - const start = source.indexOf(" const evidenceFactory ="); + const start = source.indexOf(" const activity = acpxProfileActivity(activeAgent);"); const end = source.indexOf(" let usageBefore:", start); expect(start).toBeGreaterThan(0); const emitted: unknown[] = []; - const create = new Function("createCopilotToolEvidence", "createCursorToolEvidence", "validateAcpxRichEvent", "emit", "agent", ` + const create = new Function("acpxProfileActivity", "validateAcpxRichEvent", "emit", "agent", ` const activeHost = { identity: () => ({ backendSessionId: "session" }) }; let host = activeHost, turnId = "turn", activeCopilotEvidence; - const currentTurnId = "turn", openParams = { agent, workingDirectory: "/workspace" }; + const currentTurnId = "turn", activeAgent = agent, openParams = { agent, workingDirectory: "/workspace" }; const diagnostic = () => {}; ${stripTypeScriptTypes(source.slice(start, end))} return { evidence: toolEvidence, retire: () => { turnId = null; } }; - `)(createCopilotToolEvidence, createCursorToolEvidence, validateAcpxRichEvent, (...args: unknown[]) => emitted.push(args), agent); + `)(acpxProfileActivity, validateAcpxRichEvent, (...args: unknown[]) => emitted.push(args), agent); const tool = { type: "tool_call", tag: "tool_call", toolCallId: "tool", kind: "execute", status: "pending", rawInput: { command: "printf private-value" } }; create.evidence?.tool(tool); expect(emitted).toHaveLength(agent === "pi" ? 0 : 1); @@ -432,8 +474,8 @@ 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) { const agent = openParams.agent, toolEvidence = undefined; ${source.slice(start, end)}`)( - permissions, { agent }, normalizeAcpxPermission, (_event: string, payload: { choices: Array<{ key: string }> }) => emitted.push(payload), + return async function(activeTurnId, agent, request, context, toolEvidence) { ${source.slice(start, end)}`)( + permissions, { agent }, normalizeAcpxPermission, (_event: string, payload: { choices: Array<{ key: string }>; origin: unknown }) => emitted.push(payload), ); const abort = new AbortController(); const pending = wait("turn-1", agent, { sessionId: "session", inferredKind: "edit", raw: { @@ -1021,6 +1063,46 @@ describe("qualified ACPX runtime sidecar", () => { }, ); + it.each(["off", "low", "high", "max"])( + "accepts Pi thinking level %s through the actual session.open dispatcher", + async (piThinkingLevel) => { + const sidecar = startSidecar(); + const model = "custom-provider/caller-selected-model"; + sidecar.write(initializeRequest(1, "pi", model)); + expect(await sidecar.next(frame => frame.id === 1)).toMatchObject({ ok: true }); + sidecar.write({ + protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, id: 2, command: "session.open", + params: { runtimeDirectory: "/unused", workingDirectory: "/unused", normalizedSessionId: "pi-open", + agent: "pi", model, permissionMode: "deny-all", piThinkingLevel, systemInstructions: "Test instructions", tools: [] }, + }); + // The host's policy gate runs after parsing and before installation or + // credentials. This proves the real process crosses the open boundary + // without starting a provider or making a model request. + expect(await sidecar.next(frame => frame.id === 2)).toMatchObject({ + ok: false, error: { message: "Pi admission requires an explicit task execution policy" }, + }); + }, + ); + + it.each([ + ["pi", { piThinkingLevel: "medium" }, "Pi thinking level"], + ["codex", { piThinkingLevel: "low" }, "only supported by Pi"], + ["pi", { piThinkingLevel: "low", unknownOption: true }, "unsupported field"], + ])("rejects invalid %s session.open fields before launch", async (agent, fields, message) => { + const sidecar = startSidecar(); + const model = "caller-selected-model"; + sidecar.write(initializeRequest(1, agent, model)); + expect(await sidecar.next(frame => frame.id === 1)).toMatchObject({ ok: true }); + sidecar.write({ + protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, id: 2, command: "session.open", + params: { runtimeDirectory: "/unused", workingDirectory: "/unused", normalizedSessionId: "invalid-open", + agent, model, permissionMode: "deny-all", tools: [], ...fields }, + }); + expect(await sidecar.next(frame => frame.id === 2)).toMatchObject({ + ok: false, error: { message: expect.stringContaining(message) }, + }); + }); + it.each([ ["cursor", "explicit-cursor-model"], ["copilot", "explicit-copilot-model"], @@ -1104,7 +1186,7 @@ class SidecarProcess { this.#child = spawn( fileURLToPath(new URL("../../node_modules/.bin/tsx", import.meta.url)), [fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url))], - { stdio: ["pipe", "pipe", "pipe"] }, + { stdio: ["pipe", "pipe", "pipe"], env: { PATH: process.env.PATH, LANG: "C.UTF-8" } }, ); let stdout = ""; this.#child.stdout.setEncoding("utf8"); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index c30fb5b30e..63fc827217 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -1,6 +1,8 @@ #!/usr/bin/env node import { parseProviderMode } from "../contracts/provider-mode.js"; import { acpxProfileActivity, type AcpxActivityAdapter, type AcpxToolEvidence } from "../drivers/acpx/profile-activity.js"; + +import { resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js"; import { createHash } from "node:crypto"; import { createInterface } from "node:readline"; import { deliverAcpxResponse, requireAcpxResponseDelivery } from "../drivers/acpx/response-delivery.js"; @@ -294,6 +296,7 @@ async function dispatch( model: params.model, permissionMode: params.permissionMode, mode: params.mode, + piThinkingLevel: params.piThinkingLevel, providerPolicy: params.providerPolicy, systemInstructions: params.systemInstructions, runtimeContext: params.runtimeContext, @@ -385,6 +388,7 @@ async function dispatch( 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."), }); + activeCopilotEvidence = toolEvidence && "captureSemanticReceipt" in toolEvidence ? toolEvidence as CopilotToolEvidence : undefined; let usageBefore: unknown; try { usageBefore = await readSidecarHostStatusWithin(activeHost); @@ -516,19 +520,22 @@ async function dispatch( } if (request.command === "session.snapshot") { const activeHost = requireHost(); + const status = sanitizeRuntimeStatus(await readSidecarHostStatusWithin(activeHost)); return { identity: acpxProviderSessionIdentity( activeHost.identity(), activeHost.binding(), ), - status: sanitizeRuntimeStatus( - await readSidecarHostStatusWithin(activeHost), - ), + status, runId, turnId, sequence, pendingToolCount: tools.size, pendingInputCount: inputs.size, + pendingRuntimeRequests: [ + ...Array.from(inputs, ([requestId, pending]) => ({ requestId, type: "input", turnId: pending.turnId })), + ...Array.from(permissions, ([requestId, pending]) => ({ requestId, type: "permission", turnId: pending.turnId })), + ], }; } if (request.command === "session.goal.get") { @@ -728,6 +735,7 @@ async function waitForTool(call: RunnerToolCall): Promise { // 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. + const commitNormalizedInput = call.captureNormalizedInput?.(validation.result); const forwarded = emit( "runtime.tool_called", { @@ -752,6 +760,7 @@ async function waitForTool(call: RunnerToolCall): Promise { // 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. + commitNormalizedInput?.(); settle(result); }, reject, @@ -1200,11 +1209,12 @@ function safeOutput(value: unknown): Record { function parseOpenParams( value: Record, ): AcpxSidecarOpenParams { - const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity"]); + const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "piThinkingLevel", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity"]); if (Object.keys(value).some(key => !fields.has(key))) throw new Error("ACPX open parameters include an unsupported field"); 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"); + if (value.mode !== undefined && agent !== "cursor") throw new Error("mode is supported only for Cursor"); + const piThinkingLevel = resolvePiThinkingLevel(agent, value.piThinkingLevel); resolveQualifiedAcpxProfile(agent, model); if ( value.providerSessionKey !== undefined && @@ -1225,6 +1235,7 @@ function parseOpenParams( model, permissionMode: requiredPermissionMode(value.permissionMode), ...(value.mode === undefined ? {} : { mode: parseProviderMode(value.mode) }), + ...(piThinkingLevel ? { piThinkingLevel } : {}), permissionModePinned: value.permissionModePinned === true, ...(value.providerPolicy == null ? {} : { providerPolicy: parseProviderPolicy(value.providerPolicy) }), systemInstructions: boundedText( @@ -1295,6 +1306,7 @@ function parseExpectedIdentity(value: unknown): AcpxExpectedSessionIdentity { ? {} : { permissionMode: requiredPermissionMode(input.permissionMode) }), ...(input.mode === undefined ? {} : { mode: parseProviderMode(input.mode) }), + ...(input.piThinkingLevel === undefined ? {} : { piThinkingLevel: resolvePiThinkingLevel("pi", input.piThinkingLevel) }), providerLifetimeFenceCandidates: requiredFenceCandidates( input.providerLifetimeFenceCandidates, ), @@ -1321,7 +1333,7 @@ function requiredFenceCandidates( 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"); + throw new Error("mode must be agent, plan, or ask"); } function requiredPermissionMode( 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 31a09c845d..7b17130721 100644 --- a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts +++ b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts @@ -35,7 +35,7 @@ function request(overrides: Record = {}): unknown { maxAgentTurns: 1, maxEstimatedCostNanodollars: 100_000_000, }, - session: {}, + session: overrides.acpxAgent === "pi" ? { piThinkingLevel: "low" } : {}, ...overrides, }; } @@ -68,13 +68,13 @@ function agentCoreProfile(overrides: Record = {}) { describe("eval-session request contract", () => { it.each(["pi", "copilot"] as const)("admits %s only with the matching CLI diagnostic opt-in", (agent) => { - const value = request({ provider: "acpx", acpxAgent: agent, model: "explicit-provider-model" }); + const value = request({ provider: "acpx", acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), model: "explicit-provider-model" }); expect(() => parseEvalSessionRequest(value)).toThrow("--candidate-profile"); - expect(parseEvalSessionRequest(value, { candidateProfile: agent })).toMatchObject({ acpxAgent: agent, model: "explicit-provider-model" }); + expect(parseEvalSessionRequest(value, { candidateProfile: agent })).toMatchObject({ acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), model: "explicit-provider-model" }); expect(() => parseEvalSessionRequest(value, { candidateProfile: agent === "pi" ? "cursor" : "pi" })).toThrow("must match"); - expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, model: "" }), { candidateProfile: agent })).toThrow("request.model"); + expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), model: "" }), { candidateProfile: agent })).toThrow("request.model"); expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: "codex", session: { acpxAgent: agent } }))).toThrow("session.acpxAgent must match"); - expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, candidateProfile: agent }))).toThrow("--candidate-profile"); + expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), candidateProfile: agent }))).toThrow("--candidate-profile"); }); it("admits qualified Cursor without a diagnostic flag and preserves its explicit model", () => { @@ -106,7 +106,7 @@ describe("eval-session request contract", () => { 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, + await writeFile(requestPath, JSON.stringify(request({ provider: "acpx", acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), model, runnerd: { path: join(workspace, "missing-runnerd"), sha256: "a".repeat(64) }, session: { workingDirectory: workspace }, }))); @@ -216,7 +216,7 @@ describe("eval-session request contract", () => { it("requires explicit Pi diagnosis and accepts both qualified remote provider profiles", () => { expect(() => parseEvalSessionRequest(request({ provider: "acpx", - acpxAgent: "pi", + acpxAgent: "pi", piThinkingLevel: "low", }))).toThrow("--candidate-profile"); expect(parseEvalSessionRequest(request({ provider: "aws_agentcore", @@ -337,7 +337,7 @@ describe("eval-session usage", () => { it.each(["pi", "cursor", "copilot"] as const)("keeps %s oracle results when the current turn explicitly has unavailable usage", (agent) => { const parsed = parseEvalSessionRequest(request({ - provider: "acpx", acpxAgent: agent, model: "exact-provider-model", + provider: "acpx", acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), model: "exact-provider-model", }), { candidateProfile: agent }); const unavailable = { turnId: "turn-candidate", attemptId: "attempt-1", agent, @@ -442,7 +442,7 @@ describe("eval-session usage", () => { estimatedCostNanodollars: null, pricingVersion: null, ratesUsdPerMillionTokens: null, costCoverage: "unpriced", }); - const parsed = parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, model: "exact-provider-model[context=272k]" }), { candidateProfile: agent as "pi" | "cursor" | "copilot" }); + const parsed = parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), model: "exact-provider-model[context=272k]" }), { candidateProfile: agent as "pi" | "cursor" | "copilot" }); expect(() => boundedEvalSessionUsage(parsed, { turnId: "candidate-turn", status: "completed", assistantText: "Completed provider response", snapshot, })).toThrow("budget cost coverage is unavailable"); @@ -474,7 +474,7 @@ describe("eval-session budget settlement", () => { providerModel: { id: model, provider: agent === "grok" ? "xai" : "github" }, status: "idle", activeTurnId: null, createdAt: "2026-09-28T19:00:00.000Z", updatedAt: "2026-09-28T19:00:01.000Z", authority: { companyId: "company-1", actorId: "actor-1", taskId: "task-1", runId: "run-1", sessionId: "session-1", scenarioId: "budget-test" }, - config: { provider: "acpx", acpxAgent: agent, driver: "acpx_runtime", seedState: state, workingDirectory: workspace, scenario: { id: "budget-test" }, capabilities: [], explicitClaims: [], turnTimeoutMs: 1000 }, + config: { provider: "acpx", acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), driver: "acpx_runtime", seedState: state, workingDirectory: workspace, scenario: { id: "budget-test" }, capabilities: [], explicitClaims: [], turnTimeoutMs: 1000 }, mockState: state, process: null, networkEvidence: { realPaperclipRequests: 0, childPaperclipEnvironmentKeys: [] }, transcript: [{ id: "assistant-1", role: "assistant", text: "Retained actual provider response", turnId: "turn-1", at: "2026-09-28T19:00:01.000Z" }], evidence: [], authorizationRecords: [], attempts: [], terminalTurns: [{ turnId: "turn-1", status: "completed" }], @@ -484,7 +484,7 @@ describe("eval-session budget settlement", () => { const sendMessage = vi.fn(async () => ({ turnId: "turn-1", status: "completed", assistantText: "Retained actual provider response", snapshot })); const completeAttempt = vi.fn(async () => undefined); const shutdown = vi.fn(async () => { snapshot.status = "closed"; }); - const input = request({ provider: "acpx", acpxAgent: agent, model, + const input = request({ provider: "acpx", acpxAgent: agent, ...(agent === "pi" ? { piThinkingLevel: "low" } : {}), model, runnerd: { path: binary, sha256: createHash("sha256").update("unused fake runner").digest("hex") }, session: { workingDirectory: workspace }, limits: { turnTimeoutMs: 1000, maxAgentTurns: 2, maxEstimatedCostNanodollars: 100_000_000 }, }); diff --git a/packages/paperclip-runner/src/cli/eval-session-contract.ts b/packages/paperclip-runner/src/cli/eval-session-contract.ts index 3e4c229cb3..d134af8308 100644 --- a/packages/paperclip-runner/src/cli/eval-session-contract.ts +++ b/packages/paperclip-runner/src/cli/eval-session-contract.ts @@ -1,3 +1,5 @@ +import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js"; +import { resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js"; import type { CapabilityLiveSessionSnapshot, CreateCapabilityLiveSessionInput, @@ -69,6 +71,7 @@ export interface EvalSessionRequest { driver?: EvalSessionDriver; opencodeVersion?: string; acpxAgent?: QualifiedAcpxAgent; + piThinkingLevel?: "off" | "low" | "high" | "max"; managedProfile?: EvalSessionManagedProfile; agentCoreProfile?: EvalSessionAgentCoreProfile; runnerd: { path: string; sha256: string }; @@ -258,11 +261,12 @@ export function parseEvalSessionRequest( if (provider !== "acpx" && acpxAgent !== undefined) { throw new Error("eval-session acpxAgent requires provider acpx"); } + const piThinkingLevel = resolvePiThinkingLevel(provider === "acpx" ? String(acpxAgent) : "", input.piThinkingLevel); const candidate = acpxAgent === "pi" || acpxAgent === "cursor" || acpxAgent === "copilot"; if (options.candidateProfile !== undefined && (provider !== "acpx" || acpxAgent !== options.candidateProfile || !candidate)) { throw new Error("--candidate-profile must match the request's registered candidate ACPX agent"); } - if (candidate && acpxAgent !== "cursor" && options.candidateProfile !== acpxAgent) { + if (candidate && acpxAgent && ACPX_CAPABILITY_PROFILES[acpxAgent].qualification === "pending" && options.candidateProfile !== acpxAgent) { throw new Error("Candidate ACPX profiles require an explicit matching --candidate-profile diagnostic flag"); } const managedProfileInput = input.managedProfile === null @@ -327,6 +331,7 @@ export function parseEvalSessionRequest( throw new Error("request.session.acpxAgent must match request.acpxAgent"); } + if (session.piThinkingLevel !== piThinkingLevel) throw new Error("request.session.piThinkingLevel must match request.piThinkingLevel"); return { schema: EVAL_SESSION_REQUEST_SCHEMA, attemptId: text(input.attemptId, "request.attemptId"), @@ -338,6 +343,7 @@ export function parseEvalSessionRequest( ? { opencodeVersion: text(input.opencodeVersion, "request.opencodeVersion") } : {}), ...(acpxAgent === undefined ? {} : { acpxAgent }), + ...(piThinkingLevel === undefined ? {} : { piThinkingLevel }), ...(managedProfile === undefined ? {} : { managedProfile }), ...(agentCoreProfile === undefined ? {} : { agentCoreProfile }), runnerd: { diff --git a/packages/paperclip-runner/src/cli/eval-session.ts b/packages/paperclip-runner/src/cli/eval-session.ts index 237f018dc5..524d47e327 100644 --- a/packages/paperclip-runner/src/cli/eval-session.ts +++ b/packages/paperclip-runner/src/cli/eval-session.ts @@ -35,6 +35,7 @@ import { type EvalSessionUsage, } from "./eval-session-contract.js"; import { evalProviderTransportOptions } from "./eval-provider-runtime.js"; +import { NativeSessionCloseUnrecoverableError } from "../contracts/native-session-backend.js"; interface EvalSessionCliOptions { requestPath: string; @@ -103,6 +104,7 @@ const EVAL_RUNTIME_INSTRUCTIONS = [ "Use the provided Paperclip semantic tools to inspect and act on the assigned task.", "Treat the seeded control-plane state as authoritative and keep every action within the requested scope.", "The current user request defines the work for this turn. Seeded task descriptions, notes, and past interaction results are background context; they do not supersede that request or establish that a newly requested action has already been performed.", + "For a bounded request, read only the context needed for that request, perform the requested action, and end the turn. A request to record a brief progress update does not require investigating unrelated history or documents.", "Task-state changes in this mock control plane use finish_task and block_task. Native paperclip_finish and paperclip_block report the provider run result but do not update the mock task. When asked to finish or block the assigned task, use its task-state semantic operation before reporting the run result.", "Do not finish or block the mock task unless the current request asks for that state change. Ending the provider turn after another requested action does not authorize additional task-state changes or completion comments.", "", @@ -224,8 +226,28 @@ function failureClass(error: unknown): { class: string; category: string; retryable: boolean; - diagnostics: Record; + diagnostics: Record; } { + if (error instanceof NativeSessionCloseUnrecoverableError) { + const settlement = error.settlement ?? {}; + const state = settlement.suspensionState !== null && typeof settlement.suspensionState === "object" + ? settlement.suspensionState as Record : {}; + const closedValue = (value: unknown, allowed: string[]) => + typeof value === "string" && allowed.includes(value) ? value : null; + const observedBoolean = (value: unknown) => typeof value === "boolean" ? value : null; + return { + class: "runner_infrastructure_failure", + category: "runner_infrastructure", + retryable: false, + diagnostics: { + runnerSuspended: observedBoolean(settlement.runnerSuspended), + providerDrained: observedBoolean(settlement.providerDrained), + suspensionCommandStatus: closedValue(state.commandStatus, ["pending", "completed", "failed", "rejected", "indeterminate"]), + runnerLifecycle: closedValue(state.runnerLifecycle, ["ready", "suspended", "closed", "recoverable_failure"]), + runnerIdentityMatches: observedBoolean(state.runnerIdentityMatches), + }, + }; + } if (error instanceof EvalSessionBudgetError && error.coverageUnknown) { return { class: "provider_budget_coverage_unknown", category: "provider_budget", retryable: false, diagnostics: {} }; } @@ -407,7 +429,7 @@ export async function runEvalSessionCli( provider: requestedProvider, requestedModel: request.model, ...(requestedProvider === "acpx" - ? { acpxAgent: request.acpxAgent ?? "codex" } + ? { acpxAgent: request.acpxAgent ?? "codex", ...(request.piThinkingLevel === undefined ? {} : { piThinkingLevel: request.piThinkingLevel }) } : { acpxAgent: undefined }), ...(request.managedProfile === undefined ? {} @@ -450,6 +472,7 @@ export async function runEvalSessionCli( driver: requestedDriver, providerVersion: requestedProviderVersion, providerSessionId: snapshot.providerSessionId, + ...(request.acpxAgent === "pi" ? { piThinkingLevel: snapshot.process?.piThinkingLevel ?? null } : {}), ...(requestedProvider === "claude_managed" ? { managedProfile: request.managedProfile, diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts index ee04ce4352..410496afce 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -456,6 +456,7 @@ export interface AcpxSessionIdentity { permissionMode?: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; /** Effective provider mode identifier, bound to the session identity. */ mode?: string; + piThinkingLevel?: "off" | "low" | "high" | "max"; providerLifetimeFenceCandidates: readonly [number, number, number]; } diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 3628f7ad87..460a43534c 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -117,6 +117,22 @@ describe("NativeExecutionInputV1", () => { expect(composeNativeSystemInstructions(parsed.runtimeContext, "Follow sibling.md")).toBe( `${PAPERCLIP_EXECUTION_PROMPT}\n\nFollow sibling.md\n\nRead-only instruction sibling root: /runtime/instructions`, ); + const agentFilesContext = { + ...parsed.runtimeContext, + instructions: { + ...parsed.runtimeContext.instructions, + workingCopy: { kind: "agent_files" as const, rootPath: "/runtime/current-agent-copy", entryPath: "AGENTS.md" }, + }, + }; + const agentFilesInstructions = composeNativeSystemInstructions(agentFilesContext, "Preserve my personal notes."); + expect(agentFilesInstructions).toContain("AGENT_HOME) is /runtime/current-agent-copy."); + expect(agentFilesInstructions).toContain("its absolute path may change between turns"); + expect(agentFilesInstructions).toContain("use the current $AGENT_HOME environment variable instead of an absolute agent-directory path from an earlier turn"); + expect(agentFilesInstructions).toContain("All supported files and subfolders there are restored across tasks and sessions"); + expect(agentFilesInstructions).toContain("Write task deliverables in the task working directory"); + expect(agentFilesInstructions).toContain("Preserve my personal notes."); + expect(agentFilesInstructions).toMatch(/Read-only instruction sibling root: \/runtime\/instructions$/); + expect(composeNativeSystemInstructions(parsed.runtimeContext, "Follow sibling.md")).not.toContain("$AGENT_HOME"); expect(canonicalNativeRuntimeContextDigest({ ...context, mcp: { ...context.mcp, bindingId: "native-mcp:run-2" }, @@ -325,6 +341,20 @@ describe("NativeExecutionInputV1", () => { })).toThrow("eventExpiryDays"); }); + it.each([ + [17, "sha256:a1d976c437cb736c9cce8ebe8b8baf59e571761a8d00e8b1885e72dd906d8f21"], + [18, "sha256:9d3e7d8269f1a0af94616552dfc69671932688b81dbbd5bab9ef356b3a9cbccb"], + ] as const)("decodes a persisted Pi profile-%s run without rewriting its identity", (agentProfileVersion, commandDigest) => { + const { permissionPolicy: _permissionPolicy, ...nativeProfile } = QUALIFIED_ACPX_PROFILES.pi; + const profile = { ...nativeProfile, agentProfileVersion, commandDigest }; + const persisted = { ...input, session: { ...input.session, driverKind: "acpx_runtime" }, + provider: { kind: "acpx", agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", + piThinkingLevel: "low", permissionPolicy: "interactive", profile } }; + const parsed = parseNativeExecutionInput(persisted); + expect(parsed.provider).toEqual(persisted.provider); + expect(parseNativeExecutionInput(parsed)).toEqual(parsed); + }); + it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => { const provider = { kind: "acpx", @@ -357,11 +387,11 @@ describe("NativeExecutionInputV1", () => { profile: provider.profile, }); expect(parseNativeExecutionInput(parsed)).toEqual(parsed); - for (const unsupportedVersion of [0, 13, 1.5, "12", null]) { + for (const unsupportedVersion of [0, Math.max(16, QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion) + 1, 1.5, "14", null]) { expect(() => parseNativeExecutionInput({ ...input, session: { ...input.session, driverKind: "acpx_runtime" }, - provider: { ...provider, profile: { ...provider.profile, agentProfileVersion: unsupportedVersion } }, + provider: { ...provider, piThinkingLevel: "low", profile: { ...provider.profile, agentProfileVersion: unsupportedVersion } }, })).toThrow("qualified ACPX v1 profile"); } expect(buildNativeModelEnvelope(parsed).workspace).toEqual({ cwd: "/safe/workspace" }); @@ -386,6 +416,7 @@ describe("NativeExecutionInputV1", () => { kind: "acpx", agent: declaration.agent, model: qualificationModel || "explicit-provider-model", permissionPolicy, profile, + ...(declaration.agent === "pi" ? { piThinkingLevel: "low" } : {}), }; const parsed = parseNativeExecutionInput({ ...input, @@ -533,55 +564,6 @@ describe("native task context ownership", () => { }); } - it.each(["agent", "plan", "ask"])("round-trips Cursor mode %s through the closed execution contract", cursorMode => { - const { qualificationModel: _model, reportedModelId: _reported, permissionPolicy: _permission, - modelPolicy: _policy, qualificationStatus: _status, ...profile } = QUALIFIED_ACPX_PROFILES.cursor; - const value = { ...currentInput(), session: { ...currentInput().session, driverKind: "acpx_runtime" }, - provider: { kind: "acpx", agent: "cursor", model: "explicit-model", permissionMode: "deny-all", cursorMode, profile } }; - const result = parseNativeExecutionInput(value); - expect(result.provider).toEqual(value.provider); - expect(parseNativeExecutionInput(result)).toEqual(result); - for (const mode of [null, "", "PLAN", "auto", true, { toString: () => "plan" }]) { - expect(() => parseNativeExecutionInput({ ...value, provider: { ...value.provider, cursorMode: mode } })).toThrow("cursorMode"); - } - expect(() => parseNativeExecutionInput({ ...value, provider: { ...value.provider, agent: "copilot" } })).toThrow("cursorMode"); - }); - - it.each([ - { driverKind: "opencode_server", provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731", permissionMode: "deny" } }, - { driverKind: "acpx_runtime", provider: { - kind: "acpx", agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", permissionMode: "deny-all", - profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.13.1", agent: "pi", agentProfileVersion: 1, - agentServerPackage: "pi-acp", agentServerVersion: "0.0.33", agentRuntimePackage: "@earendil-works/pi-coding-agent", - agentRuntimeVersion: "0.84.2", commandDigest: `sha256:${"a".repeat(64)}` }, - } }, - { driverKind: "openai_dot_mcp", provider: { - kind: "openai_dot", model: null, - binding: { bindingId: "dot-binding", bindingGeneration: 1, companyId: input.binding.companyId, agentId: input.binding.agentId, - acceptByUnixMs: 1_000, expiresAtUnixMs: 2_000 }, - } }, - ])("preserves an unregistered saved prompt for $provider.kind", ({ driverKind, provider }) => { - const current = currentInput(); - if (!("runtimeContext" in current)) throw new Error("Expected a runtime context"); - const text = "Saved instructions absent from the current release."; - const context = { ...current.runtimeContext, - prompt: { revision: "saved-prompt-before-upgrade", text, digest: createHash("sha256").update(text).digest("hex") } }; - context.aggregateDigest = canonicalNativeRuntimeContextDigest(context); - const persisted = JSON.parse(JSON.stringify({ - ...current, provider, session: { ...current.session, driverKind }, runtimeContext: context, - ...(provider.kind === "openai_dot" ? { - schema: NATIVE_EXECUTION_INPUT_SCHEMA_V6, - workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null }, - credentialBindings: [], - } : {}), - })); - const recovered = parseNativeExecutionInput(persisted); - expect(recovered.runtimeContext).toEqual(context); - expect(recovered.provider).toEqual(provider); - expect(recovered.session.driverKind).toBe(driverKind); - expect(parseNativeExecutionInput(recovered)).toEqual(recovered); - }); - it("carries an opaque provider mode without a vendor restriction and fences obsolete field names", () => { const current = currentInput(); const provider = { diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index 1e1e1acf6d..194920c3e9 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -130,6 +130,7 @@ export type NativeProviderConfig = model: string; permissionMode?: NativeAcpxPermissionMode; mode?: string; + piThinkingLevel?: "off" | "low" | "high" | "max"; /** Present only in persisted v1-v3 inputs. */ permissionPolicy?: "interactive"; profile: NativeAcpxProfileSnapshot; @@ -145,6 +146,7 @@ export type NativeProviderConfigV4 = model: string; permissionMode: NativeAcpxPermissionMode; mode?: string; + piThinkingLevel?: "off" | "low" | "high" | "max"; profile: NativeAcpxProfileSnapshot; }; @@ -502,7 +504,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", ...(isV4 ? ["mode"] : [])] + ? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile", "piThinkingLevel", ...(isV4 ? ["mode"] : [])] : provider.kind === "codex" && isV4 ? ["kind", "model", "approvalPolicy", ...(isV5 ? ["reasoningEffort"] : [])] : provider.kind === "opencode" && isV4 @@ -622,6 +624,9 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput invocationLimits: { maxIterations, maxOutputTokens, timeoutSeconds }, }; } else if (provider.kind === "acpx") { + if (provider.piThinkingLevel !== undefined && (provider.agent !== "pi" || (typeof provider.piThinkingLevel !== "string" || !["off", "low", "high", "max"].includes(provider.piThinkingLevel)))) { + throw new NativeExecutionInputError("input.provider.piThinkingLevel must be off, low, high, or max and is supported only for Pi"); + } if (provider.mode !== undefined && !isProviderMode(provider.mode)) { throw new NativeExecutionInputError("input.provider.mode must be a bounded nonempty provider mode identifier"); } @@ -660,6 +665,9 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput ) { throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile"); } + if (provider.agent === "pi" && profile.agentProfileVersion >= 13 && provider.piThinkingLevel === undefined) { + throw new NativeExecutionInputError("input.provider.piThinkingLevel is required for Pi profile 13 or later"); + } const runtimePackage = nullableText(profile.agentRuntimePackage, "input.provider.profile.agentRuntimePackage"); const runtimeVersion = nullableText(profile.agentRuntimeVersion, "input.provider.profile.agentRuntimeVersion"); if ((runtimePackage === null) !== (runtimeVersion === null)) { @@ -673,6 +681,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput ? { permissionMode: provider.permissionMode as NativeAcpxPermissionMode } : { permissionPolicy: "interactive" as const }), ...(provider.mode === undefined ? {} : { mode: provider.mode as string }), + ...(provider.piThinkingLevel === undefined ? {} : { piThinkingLevel: provider.piThinkingLevel as "off" | "low" | "high" | "max" }), profile: { driverKind: "acpx_runtime", protocolVersion: 1, diff --git a/packages/paperclip-runner/src/contracts/runtime-context.ts b/packages/paperclip-runner/src/contracts/runtime-context.ts index 1d0127e50d..810556ac49 100644 --- a/packages/paperclip-runner/src/contracts/runtime-context.ts +++ b/packages/paperclip-runner/src/contracts/runtime-context.ts @@ -173,7 +173,7 @@ export function composeNativeSystemInstructions(context: NativeRuntimeContextSna entryContent.trim(), context.connectionInstructions?.text, context.instructions.workingCopy?.kind === "agent_files" - ? `Your persistent agent directory (AGENT_HOME) is ${context.instructions.workingCopy.rootPath}. Your instruction entry is ${context.instructions.workingCopy.entryPath}, relative to that directory. All supported files and subfolders there are restored across tasks and sessions, and collected after this provider stops. Write task deliverables in the task working directory. Only changed or deleted files synchronize; the last sync wins for the same file. Temporary copies are cleaned up without retaining file history. Check the save receipt before claiming persistence.` + ? `Your persistent agent directory (AGENT_HOME) is ${context.instructions.workingCopy.rootPath}. This is the current turn's copy; its absolute path may change between turns. In shell commands, use the current $AGENT_HOME environment variable instead of an absolute agent-directory path from an earlier turn. Your instruction entry is ${context.instructions.workingCopy.entryPath}, relative to that directory. All supported files and subfolders there are restored across tasks and sessions, and collected after this provider stops. Write task deliverables in the task working directory. Only changed or deleted files synchronize; the last sync wins for the same file. Temporary copies are cleaned up without retaining file history. Check the save receipt before claiming persistence.` : context.instructions.workingCopy ? `Your editable agent instruction file is ${context.instructions.workingCopy.rootPath}/${context.instructions.workingCopy.entryPath}. Edit this registered private copy normally. After this run stops, Paperclip saves changed content as a persistent revision if your responsible user still has permission and the baseline has not changed. Check the run's instruction-save receipt before claiming persistence. Conflicts are preserved for explicit resolution. Repository instruction files, skills, and this run's loaded prompt are separate and are not collected.` : null, diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts index 72315f6e6f..24e9fc98fc 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts @@ -550,6 +550,8 @@ it("preserves the controller-selected ACPX provider package root", () => { it.each([ ["pi", "OPENROUTER_API_KEY"], + ["pi", "AWS_ACCESS_KEY_ID"], + ["pi", "CUSTOM_PI_API_KEY"], ["cursor", "CURSOR_API_KEY"], ["cursor", "CURSOR_AUTH_TOKEN"], ["copilot", "COPILOT_GITHUB_TOKEN"], @@ -557,13 +559,14 @@ it.each([ const launches: RunnerProcessLaunchSpec[] = []; vi.stubEnv(key, "ambient-must-not-cross"); vi.stubEnv(ACPX_CREDENTIAL_BINDING_ENV, "ambient-forged-marker"); + const custom = key === "CUSTOM_PI_API_KEY" ? { PAPERCLIP_PI_PROVIDERS: JSON.stringify({ private: { baseUrl: "https://provider.invalid", apiKey: key } }) } : {}; try { for (const explicit of [true, false]) { const environment = createCapabilityRunnerdProviderEnvironment({ provider: "acpx", identity, codexHome: "/fixture/home", runtimeContextPath: "/fixture/context.json", hasRuntimeContext: false, options: { acpxAgent: agent, - environment: explicit ? { [key]: "explicit-fixture-credential", [ACPX_CREDENTIAL_BINDING_ENV]: "caller-forged-marker", DATABASE_URL: "must-not-cross" } : undefined }, + environment: explicit ? { ...custom, [key]: "explicit-fixture-credential", [ACPX_CREDENTIAL_BINDING_ENV]: "caller-forged-marker", DATABASE_URL: "must-not-cross" } : undefined }, }); const handle = spawnRunner({ connection: { mode: "connect", connectUrl: "ws://127.0.0.1:43127" }, @@ -582,7 +585,7 @@ it.each([ const receipt = launch.environment[ACPX_CREDENTIAL_BINDING_ENV]; expect(receipt).toBeDefined(); expect(JSON.parse(receipt!)).toEqual({ schema: "paperclip.acpx_credential_binding.v1", agent, - sessionId: identity.normalizedSessionId, names: explicit ? [key] : [] }); + sessionId: identity.normalizedSessionId, names: explicit ? [...(key === "CUSTOM_PI_API_KEY" ? ["PAPERCLIP_PI_PROVIDERS"] : []), key] : [] }); expect(receipt).not.toContain("fixture-credential"); expect(launch.environment.DATABASE_URL).toBeUndefined(); const provider = createAcpxSidecarHostEnvironment(launch.environment, agent, identity.normalizedSessionId); diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index e0aef3887b..4506427c6d 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -30,7 +30,8 @@ import type { Duplex } from "node:stream"; import { fileURLToPath } from "node:url"; import { NativeSessionProtocolIntegrityError } from "../contracts/native-session-backend.js"; -import { ACPX_CREDENTIAL_BINDING_ENV, ACPX_CREDENTIAL_NAMES, CLAUDE_ROUTING_ENV_KEYS } from "../drivers/acpx/environment.js"; +import { ACPX_CREDENTIAL_BINDING_ENV, ACPX_CREDENTIAL_NAMES, CLAUDE_ROUTING_ENV_KEYS, createAcpxSidecarHostEnvironment } from "../drivers/acpx/environment.js"; +import { piCredentialNames } from "../drivers/acpx/pi-provider-config.js"; import { githubCredentialEnvironment } from "../github-credential-environment.js"; import { validatePrpEvent, @@ -3392,7 +3393,7 @@ const runnerExplicitProviderEnvironmentKeys = [ "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", - ...ACPX_CREDENTIAL_NAMES.pi, + ...ACPX_CREDENTIAL_NAMES.pi.filter(name => !name.startsWith("AWS_")), ...ACPX_CREDENTIAL_NAMES.cursor, ...ACPX_CREDENTIAL_NAMES.copilot, ACPX_CREDENTIAL_BINDING_ENV, @@ -3431,6 +3432,7 @@ const runnerExplicitProviderEnvironmentKeys = [ function runnerEnvironment( ticket: string, + normalizedSessionId: string, explicitSource?: NodeJS.ProcessEnv, ): NodeJS.ProcessEnv { const platformSource = explicitSource ?? process.env; @@ -3454,7 +3456,21 @@ function runnerEnvironment( if (explicitSource[key] !== undefined) environment[key] = explicitSource[key]; } } - Object.assign(environment, githubCredentialEnvironment(explicitSource), configuredEnvironment(explicitSource)); + // Pi custom-provider references and direct cloud credentials cross only + // under the controller's exact run/session credential binding. + const marker = explicitSource[ACPX_CREDENTIAL_BINDING_ENV]; + let binding: unknown; + if (marker !== undefined && Buffer.byteLength(marker) <= 4_096) { + try { binding = JSON.parse(marker); } catch { /* Sidecar admission rejects invalid markers. */ } + } + if (binding !== null && typeof binding === "object" && !Array.isArray(binding) + && (binding as Record).agent === "pi") { + const bound = createAcpxSidecarHostEnvironment(explicitSource, "pi", normalizedSessionId); + for (const key of piCredentialNames(explicitSource)) { + if (bound[key] !== undefined) environment[key] = bound[key]; + } + } + Object.assign(environment, githubCredentialEnvironment(explicitSource)); } return environment; } @@ -3602,7 +3618,7 @@ export function spawnRunner(options: { } const command = options.runnerBinaryPath ?? runnerBinary; - const environment = runnerEnvironment(options.ticket, options.environment); + const environment = runnerEnvironment(options.ticket, options.identity.normalizedSessionId, options.environment); const withRestart = (handle: RunnerProcessHandle): RunnerProcessHandle => ({ ...handle, restart: (ticket) => spawnRunner({ ...options, ticket }), 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 0e515881d0..fede31d364 100644 --- a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts @@ -1,6 +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"; + import { safeCopilotEditTarget } from "./copilot-permission-context.js"; export type AcpxPermissionAction = "accept" | "accept_for_session" | "decline" | "cancel"; diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts index 90a381a580..99a260da3d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts @@ -116,7 +116,7 @@ describe("Codex ACPX harness driver", () => { }); it.each(["tool-1", "tool with spaces", "🧭/plan", "x".repeat(300)])("binds a native Cursor plan request to its actual projected tool identity: %s", async toolCallId => { - const fixture = driverFixture({ agent: "cursor", model: "explicit-test-model", cursorMode: "plan", providerPolicy: { readOnly: false } }, { + const fixture = driverFixture({ agent: "cursor", model: "explicit-test-model", mode: "plan", providerPolicy: { readOnly: false } }, { runtimeEvents: [{ type: "tool_call", tag: "tool_call", toolCallId, title: "arbitrary tool display", kind: "execute", status: "pending" }], }); const session = await fixture.driver.openSession({ runId: "run-plan-identity", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index c43d5b8ad8..871ff1cebe 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -1,6 +1,7 @@ +import { type CopilotToolEvidence } from "./copilot-tool-evidence.js"; import { isProviderMode } from "../../contracts/provider-mode.js"; import { acpxProfileActivity, type AcpxActivityAdapter, type AcpxToolEvidence } from "./profile-activity.js"; -import type { CopilotToolEvidence } from "./copilot-tool-evidence.js"; + import { requireAcpxResponseDelivery } from "./response-delivery.js"; import { createPiMessageProjection, piBoundaryClearsFinal, type PiProjectedMessageEvent } from "./pi-message-projection.js"; import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, validateAcpxRichEvent, createAcpxProfileExtensionAdapter, type AcpxExtensionInput } from "./profile-extensions.js"; @@ -146,6 +147,7 @@ export interface CodexAcpxDriverOptions { model: string; permissionMode?: NativeAcpxPermissionMode; mode?: string; + piThinkingLevel?: "off" | "low" | "high" | "max"; providerPolicy?: { readOnly: boolean }; runtimeContext?: OpenAcpxRuntimeHostOptions["runtimeContext"]; systemInstructions?: string; @@ -460,6 +462,7 @@ export class CodexAcpxDriver implements HarnessDriver { model: this.#options.model, permissionMode: this.#options.permissionMode ?? "approve-all", mode: this.#options.mode, + piThinkingLevel: this.#options.piThinkingLevel, providerPolicy: this.#options.providerPolicy, runtimeContext: this.#options.runtimeContext, systemInstructions: this.#options.systemInstructions, @@ -914,6 +917,7 @@ class CodexAcpxSession implements HarnessSession { if (!this.#emit(event.eventType, event.payload, { turnId, itemId: event.itemId })) throw new Error("ACP tool activity could not be retained"); }, }); + this.#copilotToolEvidence = toolEvidence && "captureSemanticReceipt" in toolEvidence ? toolEvidence as CopilotToolEvidence : undefined; let turn: AcpxRuntimeTurn; const usageBefore = await readUsageStatus(this.#host); try { @@ -1285,6 +1289,7 @@ class CodexAcpxSession implements HarnessSession { effectiveModel: identity.effectiveModel, permissionMode: identity.permissionMode, ...(identity.mode === undefined ? {} : { mode: identity.mode }), + ...(identity.piThinkingLevel === undefined ? {} : { piThinkingLevel: identity.piThinkingLevel }), providerLifetimeFenceCandidates: identity.providerLifetimeFenceCandidates, }, @@ -2148,6 +2153,7 @@ function validateRecoverySnapshot(snapshot: PersistedHarnessSession): void { identity.permissionMode, )) || (identity.mode !== undefined && !isProviderMode(identity.mode)) || + (identity.piThinkingLevel !== undefined && !["off", "low", "high", "max"].includes(identity.piThinkingLevel)) || !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 78a04d98e5..a5457816bf 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 @@ -232,6 +232,14 @@ describe("Codex ACPX runtime adapter", () => { let created!: AcpRuntimeOptions & { onAgentInitialize?: (result: unknown) => void }; const options = openOptions(fakeCommand()); options.profile = { ...options.profile, agent: "pi" }; + options.piThinkingLevel = "low"; + 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: "high" }, configOptions: [{ id: "thought_level", currentValue: "high" }] } }); + guard("outbound", { id: 1, method: "session/set_config_option", params: { sessionId: "backend-1", configId: input.key, value: input.value } }); + guard("inbound", { id: 1, result: { configOptions: [{ id: "thought_level", currentValue: "low" }] } }); + }); const port = await openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(), createRuntime: (value) => { created = value; return runtime; }, 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 60a6a40d55..9255b0bc94 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -1,3 +1,4 @@ +import { createPiThinkingAdmission, resolvePiThinkingLevel } from "./pi-thinking.js"; import type { ChildProcess } from "node:child_process"; import { @@ -30,9 +31,9 @@ import { import type { AcpxModelStatus } from "./model-verification.js"; import { AcpxApprovalRequiredError, decideAcpxPermission } from "./permission-policy.js"; import { ACPX_CAPABILITY_PROFILES } from "./capability-profiles.js"; -import { assertCopilotPromptPolicy, createCopilotProtocolGuard } from "./copilot-policy.js"; import { admitCursorInstructions, createCursorInstructionAdmission } from "./cursor-instructions.js"; import { createAcpxModeBinding } from "./provider-mode.js"; +import { assertCopilotPromptPolicy, createCopilotProtocolGuard } from "./copilot-policy.js"; const VERIFIED_COMMAND_SENTINEL = "paperclip-verified-acpx-command"; const DEFAULT_RUNTIME_CLOSE_TIMEOUT_MS = 2_000; @@ -302,6 +303,8 @@ export async function openQualifiedAcpxRuntime( }; const commandLaunches = { count: 0, refreshConsumedCommand: options.refreshConsumedCommand }; const modeBinding = createAcpxModeBinding(options.profile.agent, options.mode); + const selectedPiThinkingLevel = resolvePiThinkingLevel(options.profile.agent, options.piThinkingLevel); + const piThinking = selectedPiThinkingLevel ? createPiThinkingAdmission(selectedPiThinkingLevel, { restoring: options.restoringSession }) : undefined; const cursorInstructions = options.profile.agent === "cursor" ? createCursorInstructionAdmission(options.systemInstructions) : null; @@ -312,6 +315,7 @@ export async function openQualifiedAcpxRuntime( const mode = modeBinding?.createGuard(); return (direction: "inbound" | "outbound", message: unknown) => { instructions?.(direction, message); mode?.(direction, message); }; } } : {}), + ...(piThinking ? { protocolGuardFactory: () => piThinking.createGuard() } : {}), sessionStore, agentRegistry: createRegistry({ // Preserve Claude's ACP capability identity. This is metadata only: the @@ -511,6 +515,18 @@ export async function openQualifiedAcpxRuntime( await commandLaunches.refreshConsumedCommand?.(); } return ensuredHandle; + }) : piThinking ? ensuredSession.then(async (ensuredHandle) => { + handle = ensuredHandle; + options.signal?.throwIfAborted(); + if (!piThinking.isReady()) { + if (!runtime.setConfigOption) throw new Error("Pi thinking admission requires native configuration"); + await runtime.setConfigOption({ handle: ensuredHandle, key: "thought_level", value: selectedPiThinkingLevel! }); + piThinking.assertReady(); + await children.verifyLifetimeOwnership(); + options.signal?.throwIfAborted(); + await commandLaunches.refreshConsumedCommand?.(); + } + return ensuredHandle; }) : ensuredSession) .catch((error: unknown) => { throw classifySessionEnsureFailure(error); @@ -526,6 +542,7 @@ export async function openQualifiedAcpxRuntime( : await boundedHandshake; cursorInstructions?.assertReady(); modeBinding?.assertReady(); + piThinking?.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(); @@ -580,7 +597,7 @@ export async function openQualifiedAcpxRuntime( return runtimePort( runtime, handle, - { ...requireIdentity(handle), ...(modeBinding ? { mode: modeBinding.selectedMode } : {}) }, + { ...requireIdentity(handle), ...(modeBinding ? { mode: modeBinding.selectedMode } : {}), ...(selectedPiThinkingLevel ? { piThinkingLevel: selectedPiThinkingLevel } : {}) }, baseStore, children, runtimeCloseTimeoutMs, diff --git a/packages/paperclip-runner/src/drivers/acpx/command-lease-owner.test.ts b/packages/paperclip-runner/src/drivers/acpx/command-lease-owner.test.ts index 1fd684fe19..452544791a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/command-lease-owner.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/command-lease-owner.test.ts @@ -76,4 +76,33 @@ describe("ACPX verified command lease owner", () => { await owner.command.close(); expect(initial.close).toHaveBeenCalledOnce(); }); + + it("cancels a pending refresh and joins its partial-copy cleanup before retiring ownership", async () => { + const initial = lease(); + let signal!: AbortSignal; + let entered!: () => void; let drained!: () => void; + const acquiring = new Promise(resolve => { entered = resolve; }); + const cleanup = new Promise(resolve => { drained = resolve; }); + const owner = createAcpxCommandLeaseOwner(initial, async activeSignal => { + signal = activeSignal; entered(); + await new Promise(resolve => activeSignal.addEventListener("abort", () => resolve(), { once: true })); + await cleanup; + activeSignal.throwIfAborted(); + throw new Error("cancelled acquisition cannot return a lease"); + }); + owner.command.spawn(); + const refresh = owner.refreshConsumedCommand(); + const rejectedRefresh = expect(refresh).rejects.toThrow("owner is closing"); + await acquiring; + let retired = false; + const close = owner.command.close().then(() => { retired = true; }); + expect(signal.aborted).toBe(true); + await Promise.resolve(); + expect(retired).toBe(false); + expect(initial.close).not.toHaveBeenCalled(); + drained(); + await rejectedRefresh; await close; + expect(retired).toBe(true); + expect(initial.close).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/command-lease-owner.ts b/packages/paperclip-runner/src/drivers/acpx/command-lease-owner.ts index 38896668ca..961972b0c4 100644 --- a/packages/paperclip-runner/src/drivers/acpx/command-lease-owner.ts +++ b/packages/paperclip-runner/src/drivers/acpx/command-lease-owner.ts @@ -3,13 +3,14 @@ import type { VerifiedAcpxCommandLease } from "./installation-integrity.js"; /** Keep each launch single-use while owning replacements for transient ACP controls. */ export function createAcpxCommandLeaseOwner( initial: VerifiedAcpxCommandLease, - openCommand: () => Promise, + openCommand: (signal: AbortSignal) => Promise, ) { const leases = new Set([initial]); let current = initial; let consumed = false; let closing = false; let refresh: Promise | null = null; + const admission = new AbortController(); const command: VerifiedAcpxCommandLease = { spawn(...args) { if (closing) throw new Error("Verified ACPX command owner is closing"); @@ -18,6 +19,7 @@ export function createAcpxCommandLeaseOwner( }, async close() { closing = true; + admission.abort(new Error("Verified ACPX command owner is closing")); // Late acquisitions remain owned. Retry every lease whose close fails. await refresh?.catch(() => undefined); const failures: unknown[] = []; @@ -39,7 +41,7 @@ export function createAcpxCommandLeaseOwner( if (!consumed) return; if (!refresh) { refresh = Promise.resolve() - .then(openCommand) + .then(() => openCommand(admission.signal)) .then((replacement) => { leases.add(replacement); if (closing) throw new Error("Verified ACPX command owner closed during refresh"); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts index 88c9cca1f2..592e5813e9 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-extension-adapter.ts @@ -82,7 +82,7 @@ function detailFields(data: Record): Array<{ name: string; valu // Preserve the complete field path for sensitive-key redaction. The // normalizer admits only known, typed counters/flags: their numeric token // counts are not credentials, so they retain value-only redaction. - const safe = typeof value === "string" ? redactSemanticValue(value, name) : value; + const safe = typeof value === "string" ? name.split(".").reduce((safe, segment) => redactSemanticValue(safe, segment), value) : value; details.push({ name: bounded(name, 160), value: bounded(String(safe), 4000) }); } }; diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index a183c39cb1..b295784737 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -59,8 +59,36 @@ async function permissionPolicyClosure(extraClassifierImport = false): Promise ({ verifyNativeAcpxInstallation: vi.fn() })); describe("Copilot build-owned installation", () => { - it("admits the current v12 declaration and binds its source policy, receipts and patch hashes", () => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v12-identity.json", import.meta.url), "utf8")); + it("preserves v12 history and changes only the version and four shared source bindings", () => { + const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v12-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v13-identity.json", import.meta.url), "utf8")); + const changed = Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort(); + expect(changed).toEqual(["agentProfileVersion", "semanticDirectDriverSourceSha256", "semanticSchemaBundleSourceSha256", "semanticSidecarProtocolSourceSha256", "semanticSidecarSourceSha256"]); + expect(Object.keys(current.declaration).sort()).toEqual(Object.keys(prior.declaration).sort()); + expect(prior.declaration.agentProfileVersion).toBe(12); + expect(current.declaration.agentProfileVersion).toBe(13); + const sortedPrior = Object.fromEntries(Object.entries(prior.declaration).sort(([a], [b]) => a.localeCompare(b))); + expect(`sha256:${createHash("sha256").update(JSON.stringify(sortedPrior)).digest("hex")}`).toBe(prior.commandDigest); + expect(current.commandDigest).not.toBe(prior.commandDigest); + expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); + }); + + it("preserves v13 history while binding the shared live request snapshot", () => { + const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v13-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v14-identity.json", import.meta.url), "utf8")); + expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual(["agentProfileVersion", "semanticSidecarSourceSha256"]); + expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); + }); + + it("preserves v14 history while binding the corrected outbound ACPX delivery", () => { + const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v14-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v15-identity.json", import.meta.url), "utf8")); + expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual(["acpxPatchSha256", "agentProfileVersion"]); + expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); + }); + + it("admits the current v16 declaration and binds its source policy, receipts and patch hashes", () => { + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v16-identity.json", import.meta.url), "utf8")); expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY); expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT); @@ -79,8 +107,8 @@ describe("Copilot build-owned installation", () => { for (const [relative, field] of [...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES, ["copilot-policy.ts", "policySha256"], ["../../../scripts/materialize-copilot-binary.mjs", "distributionSourceSha256"], ["../../../scripts/copilot-inner-distribution.mjs", "innerDistributionSourceSha256"], ["../../../../../patches/acpx@0.13.1.patch", "acpxPatchSha256"]]) { expect(createHash("sha256").update(readFileSync(new URL(relative!, import.meta.url))).digest("hex")).toBe(identity.declaration[field!]); } - expect(readFileSync(new URL("../../../scripts/build-copilot-distribution.mjs", import.meta.url), "utf8")).toContain(identity.commandDigest); - expect(readFileSync(new URL("../../../runner/crates/runner-core/src/acpx_provider_backend.rs", import.meta.url), "utf8")).toContain(identity.commandDigest); + expect(readFileSync(new URL("../../../acpx-profiles.json", import.meta.url), "utf8")).toContain(identity.commandDigest); + expect(readFileSync(new URL("../../../runner/crates/runner-core/src/generated_acpx_profiles.rs", import.meta.url), "utf8")).toContain(identity.commandDigest); }); it("binds the complete executable permission-policy import closure", async () => { expect(await permissionPolicyClosure()).toEqual(new Set(PERMISSION_POLICY_SOURCES.map(([path]) => new URL(path, import.meta.url).href))); @@ -91,7 +119,7 @@ describe("Copilot build-owned installation", () => { expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]); }); it.each([...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v12-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v16-identity.json", import.meta.url), "utf8")); identity.declaration[field] = "0".repeat(64); const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))); expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest); @@ -137,4 +165,16 @@ describe("Copilot build-owned installation", () => { } expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled(); }); + it("rejects the historical v12 digest even when the caller supplies the current version", async () => { + const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v12-identity.json", import.meta.url), "utf8")); + vi.mocked(verifyNativeAcpxInstallation).mockClear(); + for (const override of [ + { commandDigest: prior.commandDigest }, + { agentProfileVersion: prior.declaration.agentProfileVersion }, + { commandDigest: prior.commandDigest, agentProfileVersion: prior.declaration.agentProfileVersion }, + ]) { + await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override })).rejects.toThrow("exact pinned"); + } + expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled(); + }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts index c7da8ec27d..6dce44a0e3 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import { resolveRunnerProviderAssetsRoot } from "./provider-assets-root.js"; import { verifyNativeAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js"; import { COPILOT_LAUNCH_ARGUMENTS, COPILOT_VERSION } from "./copilot-profile.js"; -import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-profiles.js"; +import { QUALIFIED_ACPX_PROFILES, type AcpxReleaseProfile } from "./qualified-profiles.js"; export const COPILOT_CLOSURE_SHA256 = Object.freeze({ "darwin-arm64": "362f2663e967fb9e34a814bac4619cbf89e6b23069c4051ab1f2f686852d0a32", @@ -11,7 +11,7 @@ export const COPILOT_CLOSURE_SHA256 = Object.freeze({ }); /** Admission primitive only. Pending candidates remain gated by qualification. */ -export async function verifyCopilotInstallation(profile: QualifiedAcpxProfile): Promise { +export async function verifyCopilotInstallation(profile: AcpxReleaseProfile): Promise { const expected = QUALIFIED_ACPX_PROFILES.copilot; if (profile.agent !== "copilot" || profile.agentProfileVersion !== expected.agentProfileVersion || profile.acpxVersion !== expected.acpxVersion || profile.agentServerPackage !== "@github/copilot" diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts index 3c2cbcb6e2..5d33731d5c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts @@ -8,7 +8,7 @@ import { safeCopilotEditTarget } from "./copilot-permission-context.js"; const LIMIT = 256; const CATEGORY = "copilot_tool_evidence_v1"; type Fields = Record; -interface Tool { kind?: string; input?: string; fields: Fields; invalid?: boolean; semanticInput?: string; semanticReceipt?: SemanticToolReceipt; read?: SingleReadEvidence } +interface Tool { kind?: string; input?: string; fields: Fields; invalid?: boolean; semanticInput?: string; semanticReceipt?: SemanticToolReceipt; read?: SingleReadEvidence; pendingReadNotice?: boolean } const record = (v: unknown): Record => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record : {}; const identity = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v); const shellIdentity = (v: unknown): v is string => typeof v === "string" && /^[A-Za-z0-9_.-]{1,80}$/u.test(v); @@ -101,7 +101,13 @@ export function createCopilotToolEvidence(binding: { if (state.kind && state.kind !== call.kind) { state.invalid = true; notice("evidence_incomplete", id, { reason: "changed_tool_kind" }); return; } state.kind = call.kind; } - if (state.kind === "read") state.read = updateSingleReadEvidence(state.read, call, binding.workingDirectory); + let publishPendingRead = false; + if (state.kind === "read") { + state.read = updateSingleReadEvidence(state.read, call, binding.workingDirectory); + if (state.read.pendingOriginInput && call.tag === "tool_call") state.pendingReadNotice = true; + if (state.pendingReadNotice && state.read.pendingOriginInput) return; + if (state.pendingReadNotice) { state.pendingReadNotice = false; publishPendingRead = true; } + } if (call.rawInput !== undefined) { // Retain only a bounded digest of the native arguments, never their text. let inputDigest: string | undefined; @@ -113,6 +119,7 @@ export function createCopilotToolEvidence(binding: { if (state.input && state.input !== fingerprint) { state.invalid = true; notice("evidence_incomplete", id, { reason: "changed_tool_input" }); return; } state.input = fingerprint; state.fields = fields; } + if (publishPendingRead) notice("tool", id, { ...state.fields, status: "pending", operation: "read", ...(state.read?.targetSha256 ? { readTargetSha256: state.read.targetSha256 } : {}) }); const status = ["pending", "in_progress", "completed", "failed"].includes(String(call.status)) ? String(call.status) : undefined; if (!status) return; const fields: Fields = { ...state.fields, status, ...(state.read?.targetSha256 ? { readTargetSha256: state.read.targetSha256 } : {}) }; diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts index 3f2243df03..4309595084 100644 --- a/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts @@ -87,18 +87,19 @@ describe("ACPX driver profile", () => { expect( validateAcpxDriverConfig({ agent: "pi", + piThinkingLevel: "low", model: "openrouter/deepseek/deepseek-v4-flash-0731", }), ).toMatchObject({ ok: true, config: { agent: "pi", permissionMode: "approve-all" }, }); - for (const agent of ["cursor", "copilot"]) { + for (const agent of ["copilot"]) { expect(validateAcpxDriverConfig({ agent, model: "explicit-model" })) .toMatchObject({ ok: false, issues: [{ path: "agent", code: "qualification_pending" }] }); } - expect(validateAcpxDriverConfig({ agent: "pi", model: "another-model" })) - .toMatchObject({ ok: false, issues: [{ path: "model", code: "invalid_model" }] }); + expect(validateAcpxDriverConfig({ agent: "pi", piThinkingLevel: "low", model: "another-model" })) + .toMatchObject({ ok: true, config: { model: "another-model" } }); expect( validateAcpxDriverConfig({ agent: "claude", diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts index c9bf2da869..c629c2fb7b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts @@ -1,3 +1,4 @@ +import { resolvePiThinkingLevel, type PiThinkingLevel } from "./pi-thinking.js"; import type { HarnessDriverConfigValidation, HarnessDriverDescriptor, @@ -21,12 +22,13 @@ const ACPX_PERMISSION_MODES = [ "approve-reads", "deny-all", ] as const; -const ACPX_CONFIG_FIELDS = new Set(["agent", "model", "permissionMode"]); +const ACPX_CONFIG_FIELDS = new Set(["agent", "model", "permissionMode", "piThinkingLevel"]); export interface ValidatedAcpxDriverConfig extends Record { agent: QualifiedAcpxAgent; model: string; permissionMode: NativeAcpxPermissionMode; + piThinkingLevel?: PiThinkingLevel; } export function acpxCapabilities( @@ -129,10 +131,13 @@ export function validateAcpxDriverConfig( ); } + let piThinkingLevel: PiThinkingLevel | undefined; + try { piThinkingLevel = resolvePiThinkingLevel(agent, config.piThinkingLevel); } catch (error) { return invalid("piThinkingLevel", "invalid_pi_thinking_level", safeErrorMessage(error)); } const validated: ValidatedAcpxDriverConfig = { agent, model, permissionMode, + ...(piThinkingLevel === undefined ? {} : { piThinkingLevel }), }; return { ok: true, config: validated, issues: [] }; } diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts index 5449b7791f..d3feedea99 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts @@ -56,6 +56,8 @@ describe("ACPX launch environment", () => { LC_ALL: "C.UTF-8", HTTPS_PROXY: "https://proxy.example", OPENROUTER_API_KEY: "openrouter-secret", + OPENAI_API_KEY: "openai-secret", + ANTHROPIC_API_KEY: "anthropic-secret", }); expect(codex.env).not.toHaveProperty("PAPERCLIP_NATIVE_MCP_TOKEN"); expect(codex.env).not.toHaveProperty( @@ -84,7 +86,7 @@ describe("ACPX launch environment", () => { }; expect(createSanitizedAcpxSpawnInput(source, "cursor").env).toEqual({ CURSOR_API_KEY: "cursor-key", CURSOR_AUTH_TOKEN: "cursor-token" }); expect(createSanitizedAcpxSpawnInput(source, "copilot").env).toEqual({ COPILOT_GITHUB_TOKEN: "copilot-key" }); - expect(createSanitizedAcpxSpawnInput(source, "pi").env).toEqual({}); + expect(createSanitizedAcpxSpawnInput(source, "pi").env).toEqual({ COPILOT_GITHUB_TOKEN: "copilot-key" }); }); it.each([ diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.ts b/packages/paperclip-runner/src/drivers/acpx/environment.ts index 069333204b..c901501555 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -1,10 +1,10 @@ -import { configuredEnvironment } from "../../configured-environment.js"; +import { PI_CREDENTIAL_NAMES, piCredentialNames } from "./pi-provider-config.js"; import type { QualifiedAcpxAgent } from "./qualified-profiles.js"; export const ACPX_CREDENTIAL_BINDING_ENV = "PAPERCLIP_ACPX_CREDENTIAL_BINDING"; export const ACPX_CREDENTIAL_NAMES: Readonly> = { grok: ["XAI_API_KEY"], - pi: ["OPENROUTER_API_KEY"], + pi: PI_CREDENTIAL_NAMES, cursor: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"], copilot: ["COPILOT_GITHUB_TOKEN"], claude: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "AWS_BEARER_TOKEN_BEDROCK"], @@ -22,7 +22,7 @@ export function createAcpxCredentialBinding( if (!isCandidate(agent)) return undefined; return JSON.stringify({ schema: "paperclip.acpx_credential_binding.v1", agent, sessionId, - names: ACPX_CREDENTIAL_NAMES[agent].filter(name => environment !== undefined + names: (agent === "pi" ? piCredentialNames(environment) : ACPX_CREDENTIAL_NAMES[agent]).filter(name => environment !== undefined && Object.hasOwn(environment, name) && Boolean(environment[name]?.trim())), }); } @@ -36,6 +36,7 @@ export function createAcpxSidecarHostEnvironment( if (!isCandidate(agent)) return environment; const invalid = () => new Error("Candidate ACPX credentials require an explicit matching session binding"); const raw = environment[ACPX_CREDENTIAL_BINDING_ENV]; + const credentialNames = agent === "pi" ? piCredentialNames(environment) : ACPX_CREDENTIAL_NAMES[agent]; let names: string[] = []; if (raw !== undefined) { if (Buffer.byteLength(raw) > 4_096) throw invalid(); @@ -46,14 +47,14 @@ export function createAcpxSidecarHostEnvironment( if (Object.keys(value).sort().join(",") !== "agent,names,schema,sessionId" || value.schema !== "paperclip.acpx_credential_binding.v1" || value.agent !== agent || value.sessionId !== sessionId || !Array.isArray(value.names) - || value.names.length > ACPX_CREDENTIAL_NAMES[agent].length - || value.names.some(name => typeof name !== "string" || !ACPX_CREDENTIAL_NAMES[agent].includes(name)) + || value.names.length > credentialNames.length + || value.names.some(name => typeof name !== "string" || !credentialNames.includes(name)) || new Set(value.names).size !== value.names.length) throw invalid(); names = value.names as string[]; } const result = { ...environment }; delete result[ACPX_CREDENTIAL_BINDING_ENV]; - for (const name of ACPX_CREDENTIAL_NAMES[agent]) { + for (const name of credentialNames) { if (names.includes(name)) { if (!Object.hasOwn(environment, name) || !environment[name]?.trim()) throw invalid(); } else { @@ -88,8 +89,8 @@ export function createSanitizedAcpxSpawnInput( ): SanitizedAcpxSpawnInput { const source = environment ?? process.env; const candidate = isCandidate(agent); - const result: NodeJS.ProcessEnv = configuredEnvironment(environment); - const credentialNames = ACPX_CREDENTIAL_NAMES[agent]; + const result: NodeJS.ProcessEnv = {}; + const credentialNames = agent === "pi" ? piCredentialNames(environment) : ACPX_CREDENTIAL_NAMES[agent]; const allowed = new Set([ "PATH", "LANG", diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index a7e34cf141..0f2f5a134a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -22,12 +22,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "pi", - "agentProfileVersion": 1, + "agentProfileVersion": 20, "agentServerPackage": "pi-acp", "agentServerVersion": "0.0.33", "agentRuntimePackage": "@earendil-works/pi-coding-agent", - "agentRuntimeVersion": "0.84.2", - "commandDigest": "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f", + "agentRuntimeVersion": "1.0.0", + "commandDigest": "sha256:465ae72460f05cae961873f09cd7cd3652dc3a6949930b7ee16303925cd3dd0e", "permissionPolicy": "interactive" }, "cursor": { @@ -35,12 +35,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "cursor", - "agentProfileVersion": 14, + "agentProfileVersion": 15, "agentServerPackage": "cursor-agent", "agentServerVersion": "2026.09.26-dd393fe", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:a5e70580e4933a1a9248cd3c1b16500c6c93e1e14913e0a98cd5ef878bd53d39", + "commandDigest": "sha256:ac8092119542c8fbe95dae18ba5ef4d3689803fec56b7d2735fa193eea42f59b", "permissionPolicy": "interactive" }, "copilot": { @@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "copilot", - "agentProfileVersion": 2, + "agentProfileVersion": 16, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + "commandDigest": "sha256:8591f9a78a16aac4cf558733cd512f09def483fc11c673504bf93be7476d994c", "qualificationStatus": "pending", "permissionPolicy": "interactive" }, diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index e89d27845d..1af6dbd43e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -43,6 +43,9 @@ const DEPENDENCY_ANCESTOR_FD_START = 5; const MAX_DEPENDENCY_ANCESTORS = 64; const PROVIDER_WATCHDOG_HANDSHAKE_TIMEOUT_MS = 2_000; const PROVIDER_GUARDIAN_HANDSHAKE_TIMEOUT_MS = 5_000; +// Cover bounded runtime close and TERM/KILL verification without allowing a +// surviving provider to hold the entire cleanup result indefinitely. +const NATIVE_SNAPSHOT_EXIT_TIMEOUT_MS = 10_000; const VERIFIED_PROVIDER_RUNTIME_TARGET_ENV = "PAPERCLIP_ACPX_VERIFIED_PROVIDER_RUNTIME_TARGET"; @@ -406,7 +409,7 @@ export interface VerifiedAcpxInstallation { readonly commandDigest: string; readonly agentServerPackageJsonPath: string | null; readonly agentRuntimePackageJsonPath: string | null; - openCommand(): Promise; + openCommand(options?: { signal?: AbortSignal }): Promise; } /** @@ -425,8 +428,8 @@ export async function verifyNativeAcpxInstallation( commandDigest: `sha256:${declaration.expectedClosureSha256}`, agentServerPackageJsonPath: declaration.manifestPath, agentRuntimePackageJsonPath: null, - async openCommand(): Promise { - const native = await createNativeAcpxDistributionSnapshot(declaration, entries); + async openCommand(options?: { signal?: AbortSignal }): Promise { + const native = await createNativeAcpxDistributionSnapshot(declaration, entries, options?.signal); const lease = commandLease( native.snapshot.roots[0]!, NATIVE_ACPX_BOOTSTRAP_NAME, "commonjs", native.bootstrap, native.commandDirectory, [], 0, "commonjs", [], @@ -1369,6 +1372,17 @@ function commandLease( ): VerifiedAcpxCommandLease { let consumed = false; let directoriesReleased = false; + let spawnedChild: ChildProcess | null = null; + let childExit: Promise | null = null; + let snapshotCleanup: Promise | null = null; + const cleanSnapshot = (): Promise => { + if (snapshotCleanup === null) { + snapshotCleanup = Promise.resolve().then(() => privateSnapshot?.close()); + // Exit-triggered cleanup remains observable at the authoritative close. + void snapshotCleanup.catch(() => undefined); + } + return snapshotCleanup; + }; const releaseDirectories = async (): Promise => { if (directoriesReleased) return; directoriesReleased = true; @@ -1384,11 +1398,35 @@ function commandLease( void releaseDirectories().catch(() => undefined); }; const close = async (): Promise => { - if (consumed) return; + if (consumed && privateSnapshot === null) return; consumed = true; verifiedBytes.fill(0); await releaseDirectories(); - await privateSnapshot?.close(); + // A spawned provider may still read the snapshot. Runtime shutdown runs in + // parallel and retires that child; do not remove its bytes or report command + // retirement until observed exit and complete snapshot deletion. + if (childExit !== null) { + let exitTimer: ReturnType | undefined; + try { + await Promise.race([ + childExit, + new Promise((_, reject) => { + exitTimer = setTimeout(() => reject(new Error( + "ACPX provider survived native snapshot retirement deadline", + )), NATIVE_SNAPSHOT_EXIT_TIMEOUT_MS); + }), + ]); + } finally { + if (exitTimer !== undefined) clearTimeout(exitTimer); + } + } + const cleanup = cleanSnapshot(); + try { + await cleanup; + } catch (error) { + if (snapshotCleanup === cleanup) snapshotCleanup = null; + throw error; + } }; return { spawn( @@ -1533,6 +1571,21 @@ function commandLease( ], }, ); + spawnedChild = child; + if (privateSnapshot !== null) { + childExit = new Promise((resolve) => { + child.once("exit", () => resolve()); + child.once("error", () => { + // A failed spawn has no process. Errors from an admitted child + // do not prove it exited and must retain its snapshot. + if (child.pid === undefined) resolve(); + }); + }); + child.once("exit", () => { void cleanSnapshot(); }); + child.once("error", () => { + if (child.pid === undefined) void cleanSnapshot(); + }); + } if (guarded) { const guardianOwnerPipe = child.stdio[ providerOwnershipFd - 1 @@ -1562,12 +1615,11 @@ function commandLease( } catch (error) { verifiedBytes.fill(0); releaseDirectoriesBestEffort(); - void privateSnapshot?.close(); + spawnedChild?.kill(); + void Promise.resolve(childExit).then(cleanSnapshot).catch(() => undefined); throw error; } releaseDirectoriesBestEffort(); - child.once("exit", () => { void privateSnapshot?.close(); }); - child.once("error", () => { void privateSnapshot?.close(); }); const sourceInput = child.stdio[COMMAND_SOURCE_FD] as Writable | null; if (sourceInput === null) { verifiedBytes.fill(0); diff --git a/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts index b130bb1185..702f523549 100644 --- a/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.test.ts @@ -94,6 +94,39 @@ async function output(child: ChildProcess): Promise<{ text: string; error: strin } describe("native ACPX execution closure", () => { + it("reuses shared parent prefixes with exactly the original levels, insertion order and sorted batches", async () => { + const declaration = await fixture(); + const entries = await readNativeAcpxDistributionEntries(declaration); + const deep = Array.from({ length: 36 }, (_, index) => `d${index}`).join("/"); + const paths = ["plain", "a-/one", "a.b/two", "a/a", "a/b/a", "a/b/c/a", "a/b/c/b", "a/bb/a", "a/c/a", "a0/a", "space dir/@scope/pkg/a", "space dir/@scope/pkg/b", "日本/é/a", `${deep}/a`, `${deep}/b`, `${deep}/more/c`]; + for (const path of paths) { + await mkdir(join(declaration.distributionRoot, dirname(path)), { recursive: true }); + await writeFile(join(declaration.distributionRoot, path), path, { mode: 0o600 }); + entries.push({ path, sha256: hash(path), size: Buffer.byteLength(path), executable: false }); + } + entries.sort((a, b) => a.path < b.path ? -1 : 1); + const creatingStart = vi.mocked(mkdir).mock.calls.length; + const sealingStart = vi.mocked(chmod).mock.calls.length; + const created = await createNativeAcpxDistributionSnapshot({ ...declaration, expectedClosureSha256: hash(JSON.stringify(entries)) }, entries); + try { + const root = created.snapshot.roots[0]!; + const levels = new Map>(); + const directories = new Set([dirname(root), root]); + // Frozen reference: the original repeated-prefix planner. + for (const entry of entries) { + const parts = entry.path.split("/"); + for (let depth = 1; depth < parts.length; depth++) { + const path = join(root, ...parts.slice(0, depth)); + const level = levels.get(depth) ?? new Set(); + level.add(path); levels.set(depth, level); directories.add(path); + } + } + const expectedCreation = [root, ...[...levels.keys()].sort((a, b) => a - b).flatMap(depth => [...levels.get(depth)!].sort())]; + expect(vi.mocked(mkdir).mock.calls.slice(creatingStart)).toEqual(expectedCreation.map(path => [path, { mode: 0o700 }])); + expect(vi.mocked(chmod).mock.calls.slice(sealingStart)).toEqual([...directories].map(path => [path, 0o500])); + for (const path of directories) expect((await stat(path)).mode & 0o777).toBe(0o500); + } finally { await created.commandDirectory.close(); await created.snapshot.close(); } + }); it("rejects paths, ordering, oversized files and altered manifest pins", () => { const entry = { path: "runtime", sha256: "a".repeat(64), size: 10, executable: true }; for (const entries of [[{ ...entry, path: "../escape" }], [{ ...entry, path: "/absolute" }], [entry, entry], [{ ...entry, size: 2 ** 40 }], [{ ...entry, unknown: 1 }]]) { @@ -178,6 +211,109 @@ describe("native ACPX execution closure", () => { expect(() => lease.spawn()).toThrow("closed"); await lease.close(); }); + it("waits for consumed native snapshot deletion before command retirement completes", async () => { + const declaration = await fixture(); + const lease = await (await verifyNativeAcpxInstallation(declaration)).openCommand(); + const deleting = gate(), releaseDeletion = gate(); + const originalRm = vi.mocked(rm).getMockImplementation()!; + let snapshotRoot = ""; + vi.mocked(rm).mockImplementation(async (path, options) => { + if (String(path).includes("paperclip-acpx-native-")) { + snapshotRoot = String(path); + deleting.release(); + await releaseDeletion.promise; + } + return originalRm(path, options); + }); + let retired = false; + let closing: Promise | undefined; + try { + expect((await output(lease.spawn())).code).toBe(0); + await deleting.promise; + closing = lease.close().then(() => { retired = true; }); + await new Promise(resolve => setImmediate(resolve)); + expect(retired).toBe(false); + } finally { + releaseDeletion.release(); + await closing; + } + await expect(stat(snapshotRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("retains spawned native bytes until the exact child exits", async () => { + const declaration = await fixture({ script: '#!/bin/sh\nprintf ready\nexec sleep 1000\n' }); + const creatingStart = vi.mocked(mkdir).mock.calls.length; + const lease = await (await verifyNativeAcpxInstallation(declaration)).openCommand(); + const snapshotRoot = dirname(String(vi.mocked(mkdir).mock.calls[creatingStart]![0])); + const child = lease.spawn(); + const exited = once(child, "close"); + child.stderr!.resume(); + let retired = false; + let closing: Promise | undefined; + try { + await once(child.stdout!, "data"); + closing = lease.close().then(() => { retired = true; }); + await new Promise(resolve => setImmediate(resolve)); + expect(retired).toBe(false); + expect((await stat(snapshotRoot)).isDirectory()).toBe(true); + } finally { + child.kill("SIGTERM"); + await exited; + await closing; + } + await expect(stat(snapshotRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("reports exit-triggered native deletion failure and retries only its retained cleanup", async () => { + const declaration = await fixture(); + const lease = await (await verifyNativeAcpxInstallation(declaration)).openCommand(); + const deleting = gate(); + const originalRm = vi.mocked(rm).getMockImplementation()!; + let snapshotRoot = "", failed = false; + vi.mocked(rm).mockImplementation(async (path, options) => { + if (String(path).includes("paperclip-acpx-native-") && !failed) { + failed = true; + snapshotRoot = String(path); + deleting.release(); + throw new Error("native snapshot deletion denied"); + } + return originalRm(path, options); + }); + expect((await output(lease.spawn())).code).toBe(0); + await deleting.promise; + await expect(lease.close()).rejects.toThrow("native snapshot deletion denied"); + expect((await stat(snapshotRoot)).isDirectory()).toBe(true); + await lease.close(); + await expect(stat(snapshotRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("bounds retirement of a surviving native child and retains bytes for cleanup retry", async () => { + const declaration = await fixture({ script: '#!/bin/sh\nprintf ready\nexec sleep 1000\n' }); + const creatingStart = vi.mocked(mkdir).mock.calls.length; + const lease = await (await verifyNativeAcpxInstallation(declaration)).openCommand(); + const snapshotRoot = dirname(String(vi.mocked(mkdir).mock.calls[creatingStart]![0])); + const child = lease.spawn(); + const exited = once(child, "close"); + child.stderr!.resume(); + let closing: Promise | undefined; + try { + await once(child.stdout!, "data"); + vi.useFakeTimers(); + closing = lease.close(); + const disposition = Promise.race([ + closing.then(() => "closed", () => "failed"), + new Promise(resolve => setTimeout(() => resolve("unbounded"), 11_000)), + ]); + await vi.advanceTimersByTimeAsync(11_000); + expect(await disposition).toBe("failed"); + await expect(closing).rejects.toThrow("native snapshot retirement deadline"); + expect((await stat(snapshotRoot)).isDirectory()).toBe(true); + } finally { + vi.useRealTimers(); + child.kill("SIGTERM"); + await exited; + await closing?.catch(() => undefined); + await lease.close(); + } + await expect(stat(snapshotRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }); it("gives packaged executables a fresh private extraction cache each launch", async () => { const declaration = { ...await fixture(), isolatedCacheEnvironmentName: "COPILOT_PKG_CACHE_HOME" as const }; const install = await verifyNativeAcpxInstallation(declaration); @@ -221,6 +357,56 @@ describe("native ACPX execution closure", () => { const denied = await output((await (await verifyNativeAcpxInstallation(evil)).openCommand()).spawn()); expect(denied.code).not.toBe(0); expect(denied.error).toContain("escaped its closed distribution"); }, 30_000); + it.each([false, true])("runs real Node module hooks with interleaved formats and tamper=%s", async tamper => { + const script = tamper ? ` + const fs = require("node:fs"); + const { registerHooks } = require("node:module"); + const { fileURLToPath } = require("node:url"); + registerHooks({ resolve(specifier, context, next) { + const result = next(specifier, context); + if (result.url.endsWith("/value.mjs")) { + const path = fileURLToPath(result.url); + fs.chmodSync(path, 0o600); + fs.writeFileSync(path, 'console.log("tampered-code-executed");export default 99;'); + } + return result; + }}); + import("./value.mjs").then(() => { process.exitCode = 9; }) + .catch(error => { console.error(error.message); process.exitCode = 17; }); + ` : ` + const resolved = require.resolve("./value.cjs"); + require("node:fs"); + const commonjs = require(resolved); + const json = require("./value.json"); + import("./value.mjs").then(module => { + console.log(JSON.stringify([commonjs, json.value, module.default, require(resolved)])); + }).catch(error => { console.error(error); process.exitCode = 1; }); + `; + const declaration = await fixture({ node: true, script }); + const entries = await readNativeAcpxDistributionEntries(declaration); + for (const [path, source] of Object.entries({ + "value.cjs": "module.exports = 17;", + "value.json": '{"value":31}', + "value.mjs": "export default 23;", + })) { + await writeFile(join(declaration.distributionRoot, path), source, { mode: 0o600 }); + entries.push({ path, sha256: hash(source), size: Buffer.byteLength(source), executable: false }); + } + entries.sort((a, b) => a.path < b.path ? -1 : 1); + await writeFile(declaration.manifestPath, JSON.stringify({ entries })); + const lease = await (await verifyNativeAcpxInstallation({ ...declaration, expectedClosureSha256: hash(JSON.stringify(entries)) })).openCommand(); + try { + const result = await output(lease.spawn()); + if (tamper) { + expect(result.code, result.error).toBe(17); + expect(result.error).toContain("digest mismatch"); + expect(result.text).not.toContain("tampered-code-executed"); + } else { + expect(result.code, result.error).toBe(0); + expect(result.text).toBe("[17,31,23,17]\n"); + } + } finally { await lease.close(); } + }, 30_000); it("creates each private parent once and seals every directory before returning", async () => { const { declaration, entries } = await directoryFixture(); const creatingStart = vi.mocked(mkdir).mock.calls.length; @@ -366,6 +552,36 @@ describe("native ACPX execution closure", () => { await new Promise(resolve => setImmediate(resolve)); await expect(stat(removals[0]!)).rejects.toMatchObject({ code: "ENOENT" }); }); + it("aborts a pending native copy, drains admitted reads and removes its partial snapshot", async () => { + const { declaration, entries } = await manyFileFixture(Array.from({ length: 40 }, (_, index) => 91 + index)); + const prototype = await filePrototype(join(declaration.distributionRoot, "runtime")); + const originalRead = prototype.read; + const entered = gate(); const hold = gate(); const controller = new AbortController(); + const readSizes: number[] = []; const removalStart = vi.mocked(rm).mock.calls.length; + vi.spyOn(prototype, "read").mockImplementation(async function (this: FileHandle, ...args: any[]): Promise { + readSizes.push(args[0].length); entered.release(); await hold.promise; + return originalRead.apply(this, args as never); + }); + const creating = createNativeAcpxDistributionSnapshot(declaration, entries, controller.signal); + let settled = false; let unexpected: Awaited | undefined; + void creating.then(value => { settled = true; unexpected = value; }, () => { settled = true; }); + try { + await entered.promise; + controller.abort(new Error("owned command refresh cancelled")); + await new Promise(resolve => setImmediate(resolve)); + expect(settled).toBe(false); + expect(vi.mocked(rm).mock.calls).toHaveLength(removalStart); + hold.release(); + await expect(creating).rejects.toThrow("owned command refresh cancelled"); + expect(readSizes).not.toContain(123); + const removals = vi.mocked(rm).mock.calls.slice(removalStart).map(([path]) => String(path)).filter(path => /paperclip-acpx-native-/.test(path)); + expect(removals).toHaveLength(1); + await expect(stat(removals[0]!)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + hold.release(); await creating.catch(() => undefined); + if (unexpected) { await unexpected.commandDirectory.close(); await unexpected.snapshot.close(); } + } + }); it("rejects a source mutation while another file is being copied", async () => { const { declaration, entries } = await manyFileFixture([101, 102]); const prototype = await filePrototype(join(declaration.distributionRoot, "runtime")); diff --git a/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts index 11d18eba9e..83f5b334da 100644 --- a/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts @@ -81,7 +81,8 @@ export async function readNativeAcpxDistributionEntries(input: NativeAcpxDistrib * Freeze only manifest-admitted bytes. Native trees deliberately have a separate * bound; the tighter JavaScript/npm snapshot limits remain unchanged. */ -export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDistributionInput, entries: NativeAcpxDistributionEntry[]): Promise { +export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDistributionInput, entries: NativeAcpxDistributionEntry[], signal?: AbortSignal): Promise { + signal?.throwIfAborted(); // Callers cannot substitute entries between manifest validation and copying. entries = parseNativeAcpxDistributionEntries({ entries }, input.expectedClosureSha256); if (process.platform !== "linux" && process.platform !== "darwin") throw new Error("Native ACPX snapshots require Linux or macOS"); @@ -100,31 +101,44 @@ export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDist }; let commandDirectory: FileHandle | undefined; try { + signal?.throwIfAborted(); if (!same(rootBefore, await heldRoot.stat({ bigint: true }))) throw new Error("Native ACPX distribution root changed before snapshot"); await mkdir(packageRoot, { mode: 0o700 }); if (input.isolatedCacheEnvironmentName) await mkdir(cacheRoot, { mode: 0o700 }); // Build each private parent once. Level ordering prevents child creation // from racing its parent; batches bound work and drain before any cleanup. const parentLevels = new Map>(); + let previousParts: string[] = []; + const parentPaths = [packageRoot]; for (const entry of entries) { const parts = entry.path.split("/"); - for (let depth = 1; depth < parts.length; depth++) { - const path = join(packageRoot, ...parts.slice(0, depth)); + let shared = 0; + // Canonical sorted paths keep each parent prefix contiguous. Reuse the + // previous entry's joined parents instead of rebuilding every prefix. + while (shared < parts.length - 1 && shared < previousParts.length - 1 && parts[shared] === previousParts[shared]) shared++; + parentPaths.length = shared + 1; + for (let depth = shared + 1; depth < parts.length; depth++) { + const path = join(parentPaths[depth - 1]!, parts[depth - 1]!); + parentPaths.push(path); const level = parentLevels.get(depth) ?? new Set(); level.add(path); parentLevels.set(depth, level); directories.add(path); } + previousParts = parts; } const directoryBatch = async (paths: string[], operation: (path: string) => Promise): Promise => { for (let start = 0; start < paths.length; start += NATIVE_DIRECTORY_CONCURRENCY) { + signal?.throwIfAborted(); const settled = await Promise.allSettled(paths.slice(start, start + NATIVE_DIRECTORY_CONCURRENCY).map(operation)); const failed = settled.find(result => result.status === "rejected"); if (failed?.status === "rejected") throw failed.reason; + signal?.throwIfAborted(); } }; for (const depth of [...parentLevels.keys()].sort((a, b) => a - b)) { await directoryBatch([...parentLevels.get(depth)!].sort(), path => mkdir(path, { mode: 0o700 })); } const copyEntry = async (entry: NativeAcpxDistributionEntry): Promise => { + signal?.throwIfAborted(); const path = join(source, ...entry.path.split("/")); if (await realpath(path) !== path) throw new Error("Native ACPX closure contains a symbolic link"); // Bind metadata to the descriptor we will read, without a redundant @@ -137,6 +151,7 @@ export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDist const bytes = Buffer.alloc(entry.size); let offset = 0; while (offset < bytes.length) { + signal?.throwIfAborted(); const read = await file.read(bytes, offset, bytes.length - offset, offset); if (read.bytesRead === 0) throw new Error("Native ACPX closure file ended during snapshot"); offset += read.bytesRead; @@ -144,6 +159,7 @@ export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDist if (!same(before, await file.stat({ bigint: true })) || !same(before, await lstat(path, { bigint: true })) || await realpath(path) !== path) throw new Error("Native ACPX closure file changed while read"); if (sha256(bytes) !== entry.sha256) throw new Error(`Native ACPX closure file digest mismatch: ${entry.path}`); const target = join(packageRoot, ...entry.path.split("/")); + signal?.throwIfAborted(); await writeFile(target, bytes, { mode: entry.executable ? 0o500 : 0o400, flag: "wx" }); } finally { await file.close(); } }; @@ -154,20 +170,36 @@ export async function createNativeAcpxDistributionSnapshot(input: NativeAcpxDist let activeBytes = 0; let failed = false; let failure: unknown; + // Only the admission loop waits for capacity. Notify that waiter once per + // completion instead of attaching Promise.race handlers to every active + // copy on each admission (including long-lived large-file reads). + let capacityAvailable: (() => void) | undefined; for (const entry of entries) { + // An aborted acquisition must still drain its admitted reads before + // deleting the partial tree. Stop scheduling copies at the same bound + // used for any observed copy failure. + if (signal?.aborted && !failed) { failed = true; failure = signal.reason; } while (!failed && (active.size >= NATIVE_COPY_CONCURRENCY || (active.size > 0 && activeBytes + entry.size > NATIVE_COPY_BUFFER_BYTES))) { - await Promise.race(active); + await new Promise(resolve => { capacityAvailable = resolve; }); + if (signal?.aborted && !failed) { failed = true; failure = signal.reason; } } if (failed) break; activeBytes += entry.size; const copying = copyEntry(entry).catch(error => { if (!failed) { failed = true; failure = error; } - }).finally(() => { activeBytes -= entry.size; active.delete(copying); }); + }).finally(() => { + activeBytes -= entry.size; + active.delete(copying); + const notify = capacityAvailable; + capacityAvailable = undefined; + notify?.(); + }); active.add(copying); } await Promise.all(active); if (failed) throw failure; + signal?.throwIfAborted(); // Completion order must not change the module guard or manifest. for (const entry of entries) digests[join(packageRoot, ...entry.path.split("/"))] = entry.sha256; if (!same(rootBefore, await heldRoot.stat({ bigint: true })) || !same(rootBefore, await lstat(source, { bigint: true }))) throw new Error("Native ACPX distribution root changed during snapshot"); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts index 756774b33e..9261078097 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts @@ -259,6 +259,19 @@ describe("Pi ACP bridge", () => { } }); }); + it("preserves usage without reporting an acknowledged cancellation as a service failure", () => { + const usage = new PiTurnUsage(); + usage.accept({ role: "assistant", timestamp: 1, stopReason: "error", usage: { input: 4, output: 2 } }); + expect(usage.response("cancelled")).toEqual({ usage: { + inputTokens: 4, outputTokens: 2, _meta: { paperclipPi: { provenance: "assistant_message_receipts" } }, + } }); + // Ordinary settlement keeps the provider failure and partial accounting. + expect(usage.response("end_turn")).toMatchObject({ + usage: { inputTokens: 4, outputTokens: 2 }, + _meta: { jetbrains: { air: { sessionFailure: { severity: "error" } } } }, + }); + }); + it("accounts compaction receipts once and does not fabricate partial coverage", () => { const usage = new PiTurnUsage(); usage.accept({ role: "assistant", timestamp: 1, stopReason: "error", usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } } }); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts index e05311a41b..bb2049ff7e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts @@ -568,10 +568,10 @@ export class PiTurnUsage { this.failed = previousFailure; } - response(): RecordValue { + response(stopReason?: "end_turn" | "cancelled"): RecordValue { return { ...(this.observed ? { usage: { ...Object.fromEntries(Object.entries(this.total).filter(([name]) => !this.unknown.has(name as keyof typeof this.total))), _meta: { paperclipPi: { provenance: this.compactionObserved ? "assistant_message_and_compaction_receipts" : "assistant_message_receipts", ...(this.costObserved && !this.costIncomplete ? { costUsd: this.cost, costSource: "pi_pricing_estimate" } : {}) } } } } : {}), - ...(this.failed ? { _meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "error", category: "service", title: "Pi provider request failed" } } } } } : {}), + ...(this.failed && stopReason !== "cancelled" ? { _meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "error", category: "service", title: "Pi provider request failed" } } } } } : {}), }; } } diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts b/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts index 6c67b250bc..918a29a886 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts @@ -7,7 +7,7 @@ * three pins. Never accept a digest supplied only by an installed manifest. */ export const PI_DISTRIBUTION_CLOSURE_SHA256 = Object.freeze({ - "darwin-arm64": "e17be4d27c589b8f8b5de7d00686c39873fe6f902a3f133bdaf1a9f94dee00a2", - "darwin-x64": "03351f4a250a8db0e79411a9079b43a0ff05f72a2aff41fae17f1fc2de24bd41", - "linux-x64": "29dfc829700c57392f1d56373078dcfc80674cbd50f65674ec64fced81dffb68", + "darwin-arm64": "e076276c674dfffccbb488883e18571d77d7225d801fabf5c8391773ddbbfc6c", + "darwin-x64": "eafd44e672dec4966ef6f038620f003e5d492c889d475886cd66be2e9c2bff1d", + "linux-x64": "f493df174cfecba3c31c524aa486ae37663cd68f8fcc0d9556e3f615c4a40ce9", }); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts index 24cbe527f8..98d74b040b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts @@ -9,11 +9,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { PI_DISTRIBUTION_CLOSURE_SHA256 } from "./pi-closure-pins.js"; import { PI_NODE_VERSION } from "./pi-node-pins.js"; import { assertPiInstallationProfile, verifyPiInstallation } from "./pi-installation.js"; -import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-profiles.js"; +import { QUALIFIED_ACPX_PROFILES, type AcpxReleaseProfile } from "./qualified-profiles.js"; const roots: string[] = []; afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); -const candidate = (): QualifiedAcpxProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi, agentProfileVersion: 12 }); +const candidate = (): AcpxReleaseProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi }); async function fixture() { const root = await mkdtemp(join(tmpdir(), "pi-installation-test-")); roots.push(root); @@ -26,11 +26,57 @@ async function fixture() { } describe("Pi installation factory", () => { + it("preserves profile 13 and changes only the version and corrected ACPX patch binding", async () => { + const prior = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v13-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v14-identity.json", import.meta.url), "utf8")); + expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()).toEqual(["acpxPatchSha256", "agentProfileVersion"]); + }); + + it("preserves profile 14 and binds the method-specific question extension in profile 15", async () => { + const prior = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v14-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v15-identity.json", import.meta.url), "utf8")); + expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()) + .toEqual(["agentProfileVersion", "closure", "extensionSha256", "nativeQuestions"]); + expect(current.commandDigest).not.toBe(prior.commandDigest); + }); + + it("preserves profile 16 and changes only the provider credential policy in profile 17", async () => { + const prior = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v16-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v17-identity.json", import.meta.url), "utf8")); + expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()) + .toEqual(["agentProfileVersion", "providerConfigurationSourceSha256"]); + expect(current.commandDigest).not.toBe(prior.commandDigest); + }); + + it("preserves profile 17 and binds acknowledged cancellation in profile 18", async () => { + const prior = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v17-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v18-identity.json", import.meta.url), "utf8")); + expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()) + .toEqual(["agentProfileVersion", "closure", "helperSha256", "nativeCancellation", "wrapperSha256"]); + expect(current.commandDigest).not.toBe(prior.commandDigest); + }); + + it("preserves profile 18 and binds root question field types in profile 19", async () => { + const prior = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v18-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v19-identity.json", import.meta.url), "utf8")); + expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()) + .toEqual(["agentProfileVersion", "closure", "extensionSha256", "nativeQuestions"]); + expect(current.commandDigest).not.toBe(prior.commandDigest); + }); + + it("revises only the shared Codex environment attestation while preserving Pi runtime bytes", async () => { + const read = async (version: number) => JSON.parse(await readFile(new URL(`../../../test-fixtures/pi-acp/profile-v${version}-identity.json`, import.meta.url), "utf8")); + const prior = await read(19), current = await read(20); + expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()) + .toEqual(["agentProfileVersion", "runtimeSandboxSourceSha256"]); + expect(current.commandDigest).not.toBe(prior.commandDigest); + }); + it("binds the profile declaration to the reviewed patch and platform closure pins", async () => { const hash = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex"); const canonical = (value: any): string => Array.isArray(value) ? `[${value.map(canonical).join(",")}]` : value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` : JSON.stringify(value); - const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v12-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v20-identity.json", import.meta.url), "utf8")); const declaration = identity.declaration; expect(declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.commandDigest).toBe(`sha256:${hash(canonical(declaration))}`); @@ -40,6 +86,9 @@ describe("Pi installation factory", () => { } expect(declaration.closure).toEqual(PI_DISTRIBUTION_CLOSURE_SHA256); expect(declaration.nodeVersion).toBe(PI_NODE_VERSION); + for (const [path, field] of [["pi-provider-config.ts", "providerConfigurationSourceSha256"], ["environment.ts", "credentialEnvironmentSourceSha256"], ["runtime-sandbox.ts", "runtimeSandboxSourceSha256"], ["recovery-identity.ts", "recoveryIdentitySourceSha256"]]) { + expect(hash(await readFile(new URL(`./${path}`, import.meta.url)))).toBe(declaration[field!]); + } expect(hash(await readFile(new URL("../../../../../patches/acpx@0.13.1.patch", import.meta.url)))).toBe(declaration.acpxPatchSha256); expect(hash(await readFile(new URL("./pi-message-projection.ts", import.meta.url)))).toBe(declaration.messageProjectionSha256); expect(hash(await readFile(new URL("./pi-extension-adapter.ts", import.meta.url)))).toBe(declaration.noticeProjectionSha256); @@ -52,23 +101,27 @@ describe("Pi installation factory", () => { expect(materializer).toContain(`helperSha256: "${declaration.helperSha256}"`); // This exact patch produced the reviewed current wrapper/closure. // Pin it separately so wrapper-only edits cannot keep an unchanged declaration. - expect(hash(await readFile(new URL("../../../../../patches/pi-acp@0.0.33.patch", import.meta.url)))).toBe("93254de82ae779587611748cfb963475c3439e033f1582f06dfdcc7f7b937546"); + expect(hash(await readFile(new URL("../../../../../patches/pi-acp@0.0.33.patch", import.meta.url)))).toBe("a7c63900b513490cceed9e41bc3bd06f03f2cd56265f434aa2c5dc7925fa76b4"); }); - it("rejects legacy profiles and caller-selected identities", () => { + it("rejects legacy profiles and tampered executable identities", () => { expect(() => assertPiInstallationProfile(candidate())).not.toThrow(); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 12"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 12"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 12"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 12"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 12"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 12"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 12"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 12"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 12"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 12"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 12"); - for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { reportedModelId: "different" }, { agentRuntimePackage: "ambient-pi" }]) { + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 20"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 20"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 20"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 20"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 20"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 20"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 20"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 20"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 20"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 20"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 20"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 20"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 20"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 20"); + for (const priorVersion of [15, 16, 17, 18, 19]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 20"); + for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { agentRuntimePackage: "ambient-pi" }]) { expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration"); } }); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts index 2588b7d9d2..2ca4e87037 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.ts @@ -7,10 +7,9 @@ import { PI_NODE_VERSION } from "./pi-node-pins.js"; import { verifyPiRuntimeLayoutForNativeSnapshot, type PiRuntimeManifest } from "./pi-verified-runtime.js"; import { readNativeAcpxDistributionEntries } from "./native-distribution-integrity.js"; import { resolveRunnerProviderAssetsRoot } from "./provider-assets-root.js"; -import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-profiles.js"; +import { QUALIFIED_ACPX_PROFILES, type AcpxReleaseProfile } from "./qualified-profiles.js"; const MAX_DISTRIBUTION_METADATA_BYTES = 4 * 1024 * 1024; -const PI_MODEL = "openrouter/deepseek/deepseek-v4-flash-0731"; const FIXED_PATHS = Object.freeze({ node: "node/bin/node", piEntrypoint: "node_modules/@earendil-works/pi-coding-agent/dist/cli.js", @@ -23,10 +22,10 @@ function record(value: unknown): Record { } /** Candidate identity is build-owned; model selection cannot substitute a CLI. */ -export function assertPiInstallationProfile(profile: QualifiedAcpxProfile): void { +export function assertPiInstallationProfile(profile: AcpxReleaseProfile): void { const trusted = QUALIFIED_ACPX_PROFILES.pi; - if (profile.agentProfileVersion !== 12) throw new Error("Pi rich ACP requires profile version 12; reopen the previous session"); - if (profile.agent !== "pi" || profile.driverKind !== trusted.driverKind || profile.protocolVersion !== trusted.protocolVersion || profile.acpxVersion !== trusted.acpxVersion || profile.agentServerPackage !== "pi-acp" || profile.agentServerVersion !== "0.0.33" || profile.agentRuntimePackage !== "@earendil-works/pi-coding-agent" || profile.agentRuntimeVersion !== "1.0.0" || profile.commandDigest !== trusted.commandDigest || profile.permissionPolicy !== "interactive" || profile.qualificationModel !== PI_MODEL || profile.reportedModelId !== PI_MODEL) throw new Error("Pi distribution profile differs from its trusted declaration"); + if (profile.agentProfileVersion !== trusted.agentProfileVersion) throw new Error(`Pi rich ACP requires profile version ${trusted.agentProfileVersion}; reopen the previous session`); + if (profile.agent !== "pi" || profile.driverKind !== trusted.driverKind || profile.protocolVersion !== trusted.protocolVersion || profile.acpxVersion !== trusted.acpxVersion || profile.agentServerPackage !== "pi-acp" || profile.agentServerVersion !== "0.0.33" || profile.agentRuntimePackage !== "@earendil-works/pi-coding-agent" || profile.agentRuntimeVersion !== "1.0.0" || profile.commandDigest !== trusted.commandDigest || profile.permissionPolicy !== "interactive") throw new Error("Pi distribution profile differs from its trusted declaration"); } async function readDistributionMetadata(path: string): Promise> { @@ -47,7 +46,7 @@ async function readDistributionMetadata(path: string): Promise { +export async function verifyPiInstallation(profile: AcpxReleaseProfile): Promise { assertPiInstallationProfile(profile); const target = `${process.platform}-${process.arch}`; if (!Object.hasOwn(PI_DISTRIBUTION_CLOSURE_SHA256, target)) throw new Error("Pi distribution target is unsupported"); @@ -60,7 +59,11 @@ export async function verifyPiInstallation(profile: QualifiedAcpxProfile): Promi if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(path) !== path) throw new Error("Pi distribution escaped its fixed asset directory"); } }; - await assertDirectories(); + try { await assertDirectories(); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error("Pi runtime is not installed on this host; run paperclipai runtime setup pi before selecting Pi"); + throw error; + } const metadataPath = join(assets, "pi-distribution.json"); const metadata = await readDistributionMetadata(metadataPath); const targetMetadata = record(metadata.target); @@ -88,12 +91,13 @@ export async function verifyPiInstallation(profile: QualifiedAcpxProfile): Promi commandDigest: profile.commandDigest, agentServerPackageJsonPath: join(runtimeRoot, "node_modules/pi-acp/package.json"), agentRuntimePackageJsonPath: join(runtimeRoot, "node_modules/@earendil-works/pi-coding-agent/package.json"), - async openCommand() { + async openCommand(options?: { signal?: AbortSignal }) { + options?.signal?.throwIfAborted(); await assertDirectories(); // The native primitive reads every admitted file through held descriptors // into a new immutable snapshot. The bootstrap derives its own launch // environment from that snapshot, never these mutable installation paths. - return native.openCommand(); + return native.openCommand(options); }, }); } diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-native-admission.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-native-admission.test.ts index 7d2fec870c..33266401e8 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-native-admission.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-native-admission.test.ts @@ -51,6 +51,7 @@ it("rejects corrupt Pi bytes at the command boundary before runtime, spawn or br await expect(AcpxRuntimeHost.open({ runtimeDirectory: join(root, "runtime"), normalizedSessionId: "pi-single-pass-rejection", workingDirectory: workspace, agent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731", + piThinkingLevel: "low", permissionMode: "approve-all", providerPolicy: { readOnly: true }, semanticTools: { tools: [], handler: vi.fn() }, }, { diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-provider-config.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-provider-config.test.ts new file mode 100644 index 0000000000..60c29152b9 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-provider-config.test.ts @@ -0,0 +1,71 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, it, vi } from "vitest"; +import { ACPX_CREDENTIAL_BINDING_ENV, createAcpxCredentialBinding, createAcpxSidecarHostEnvironment, createSanitizedAcpxSpawnInput } from "./environment.js"; +import { assertPiInstallationProfile } from "./pi-installation.js"; +import { requireVerifiedAcpxModel } from "./model-verification.js"; +import { piProviderConfiguration } from "./pi-provider-config.js"; +import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; +import { createAcpxRecoveryBinding } from "./recovery-identity.js"; + +describe("Pi caller-selected providers and models", () => { + it.each(["openrouter/vendor/new-model", "anthropic/user-selected-model", "openai/user-selected-model", "custom/local-model"])("selects %s without a qualification-model allowlist", async model => { + const profile = resolveQualifiedAcpxProfile("pi", model); + expect(() => assertPiInstallationProfile(profile)).not.toThrow(); + let current = "different/default"; + const setModel = vi.fn(async (selected: string) => { current = selected; }); + await expect(requireVerifiedAcpxModel({ getStatus: async () => ({ models: { currentModelId: current, availableModelIds: [] } }), setModel }, profile)) + .resolves.toMatchObject({ models: { currentModelId: model } }); + expect(setModel).toHaveBeenCalledExactlyOnceWith(model); + await expect(requireVerifiedAcpxModel({ getStatus: async () => ({ models: { currentModelId: "wrong/model" } }), setModel: async () => {} }, profile)).rejects.toThrow(); + expect(() => assertPiInstallationProfile({ ...profile, commandDigest: `sha256:${"0".repeat(64)}` })).toThrow(); + }); + + it("forwards only explicitly bound Pi credentials, including custom-provider references", () => { + const environment = { OPENAI_API_KEY: "openai-fixture", ANTHROPIC_API_KEY: "anthropic-fixture", CUSTOM_MODEL_KEY: "custom-fixture", + PAPERCLIP_PI_PROVIDERS: JSON.stringify({ custom: { baseUrl: "https://models.example/v1", api: "openai-completions", apiKey: "CUSTOM_MODEL_KEY", models: [{ id: "new-model" }] } }), + PAPERCLIP_API_KEY: "control-plane-fixture", NODE_OPTIONS: "--inspect" }; + const binding = createAcpxCredentialBinding(environment, "pi", "session-1"); + const bound = createAcpxSidecarHostEnvironment({ ...environment, [ACPX_CREDENTIAL_BINDING_ENV]: binding }, "pi", "session-1"); + expect(createSanitizedAcpxSpawnInput(bound, "pi").env).toMatchObject({ OPENAI_API_KEY: "openai-fixture", ANTHROPIC_API_KEY: "anthropic-fixture", CUSTOM_MODEL_KEY: "custom-fixture" }); + expect(createSanitizedAcpxSpawnInput(bound, "pi").env).not.toHaveProperty("PAPERCLIP_API_KEY"); + expect(createSanitizedAcpxSpawnInput(bound, "pi").env).not.toHaveProperty("NODE_OPTIONS"); + expect(() => createAcpxSidecarHostEnvironment({ ...environment, [ACPX_CREDENTIAL_BINDING_ENV]: binding }, "pi", "another-session")).toThrow(); + expect(() => createAcpxSidecarHostEnvironment(environment, "pi", "session-1")).toThrow(); + vi.stubEnv("OPENAI_API_KEY", "ambient-fixture"); + try { expect(createSanitizedAcpxSpawnInput(undefined, "pi").env).not.toHaveProperty("OPENAI_API_KEY"); } + finally { vi.unstubAllEnvs(); } + }); + + it.each(["!cat /private/key", "PAPERCLIP_API_KEY", "NODE_OPTIONS", "PATH"])("rejects unsafe custom-provider credential reference %s", apiKey => { + expect(() => piProviderConfiguration({ PAPERCLIP_PI_PROVIDERS: JSON.stringify({ custom: { apiKey } }) })).toThrow(); + }); + + it("reserves loader and shell controls at the controller boundary", async () => { + const names = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/reserved-credential-names.json", import.meta.url), "utf8")); + for (const apiKey of names) { + expect(() => piProviderConfiguration({ PAPERCLIP_PI_PROVIDERS: JSON.stringify({ custom: { apiKey } }) }), apiKey).toThrow(); + } + for (const apiKey of ["LD_API_KEY", "DYLD_API_KEY", "MY_PI_SERVICE_KEY"]) { + expect(piProviderConfiguration({ PAPERCLIP_PI_PROVIDERS: JSON.stringify({ custom: { apiKey } }) })?.credentialNames).toEqual([apiKey]); + } + }); + + it("binds custom-provider configuration and model identity across recovery", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-model-recovery-")); + try { + const profile = resolveQualifiedAcpxProfile("pi", "custom/new-model"); + const configuration = (baseUrl: string) => piProviderConfiguration({ PAPERCLIP_PI_PROVIDERS: JSON.stringify({ custom: { baseUrl, api: "openai-completions", apiKey: "CUSTOM_MODEL_KEY", models: [{ id: "new-model" }] } }) })!; + const input = { runtimeDirectory: root, workingDirectory: root, normalizedSessionId: "session-1", profile, + requestedModel: profile.reportedModelId, permissionMode: "deny-all" as const, piThinkingLevel: "off" as const }; + const first = await createAcpxRecoveryBinding({ ...input, providerConfigurationDigest: configuration("https://first.example/v1").digest }); + const same = await createAcpxRecoveryBinding({ ...input, providerConfigurationDigest: configuration("https://first.example/v1").digest }); + const changed = await createAcpxRecoveryBinding({ ...input, providerConfigurationDigest: configuration("https://second.example/v1").digest }); + expect(same.profileSessionKey).toBe(first.profileSessionKey); + expect(changed.profileSessionKey).not.toBe(first.profileSessionKey); + expect(first.requestedModel).toBe("custom/new-model"); + await expect(createAcpxRecoveryBinding({ ...input, requestedModel: "custom/another-model" })).rejects.toThrow(); + } finally { await rm(root, { recursive: true, force: true }); } + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-provider-config.ts b/packages/paperclip-runner/src/drivers/acpx/pi-provider-config.ts new file mode 100644 index 0000000000..a8b5a56871 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-provider-config.ts @@ -0,0 +1,109 @@ +import { createHash } from "node:crypto"; + +// Credential discovery follows the pinned Pi SDK. This is not a model catalog: +// provider/model IDs and explicit models.json declarations remain caller-owned. +export const PI_CREDENTIAL_NAMES = Object.freeze([ + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_OAUTH_TOKEN", "GEMINI_API_KEY", "GOOGLE_CLOUD_API_KEY", "XAI_API_KEY", + "GROQ_API_KEY", "CEREBRAS_API_KEY", "MISTRAL_API_KEY", "DEEPSEEK_API_KEY", + "NVIDIA_API_KEY", "AZURE_OPENAI_API_KEY", "AI_GATEWAY_API_KEY", "ZAI_API_KEY", + "ZAI_CODING_CN_API_KEY", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "MOONSHOT_API_KEY", + "HF_TOKEN", "FIREWORKS_API_KEY", "TOGETHER_API_KEY", "BASETEN_API_KEY", "OPENCODE_API_KEY", + "KIMI_API_KEY", "META_API_KEY", "CLOUDFLARE_API_KEY", "XIAOMI_API_KEY", + "XIAOMI_TOKEN_PLAN_CN_API_KEY", "XIAOMI_TOKEN_PLAN_AMS_API_KEY", "XIAOMI_TOKEN_PLAN_SGP_API_KEY", + "ANT_LING_API_KEY", "QWEN_TOKEN_PLAN_API_KEY", "QWEN_TOKEN_PLAN_CN_API_KEY", + "TYPESAFE_API_KEY", "RADIUS_API_KEY", "COPILOT_GITHUB_TOKEN", "AWS_BEARER_TOKEN_BEDROCK", + "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", + "PAPERCLIP_PI_PROVIDERS", +]); + +// These names alter process startup rather than identify a provider credential. +const RESERVED_PI_CREDENTIAL_NAMES = new Set([ + "PATH", + "HOME", + "SHELL", + "TMPDIR", + "BASH_ENV", + "ENV", + "ZDOTDIR", + "LD_AUDIT", + "LD_LIBRARY_PATH", + "LD_PRELOAD", + "LD_DEBUG", + "LD_DEBUG_OUTPUT", + "LD_PROFILE", + "LD_PROFILE_OUTPUT", + "LD_TRACE_LOADED_OBJECTS", + "LD_ORIGIN_PATH", + "LD_BIND_NOW", + "LD_BIND_NOT", + "LD_DYNAMIC_WEAK", + "LD_HWCAP_MASK", + "LD_SHOW_AUXV", + "LD_USE_LOAD_BIAS", + "LD_VERBOSE", + "LD_WARN", + "LD_ASSUME_KERNEL", + "LD_PREFER_MAP_32BIT_EXEC", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "DYLD_FRAMEWORK_PATH", + "DYLD_FALLBACK_LIBRARY_PATH", + "DYLD_FALLBACK_FRAMEWORK_PATH", + "DYLD_VERSIONED_LIBRARY_PATH", + "DYLD_VERSIONED_FRAMEWORK_PATH", + "DYLD_ROOT_PATH", + "DYLD_IMAGE_SUFFIX", + "DYLD_SHARED_CACHE_DIR", + "GLIBC_TUNABLES", + "GCONV_PATH", + "LOCPATH", + "NLSPATH", +]); + +export function piProviderConfiguration(environment?: NodeJS.ProcessEnv): { + json: string; digest: string; credentialNames: readonly string[]; +} | undefined { + const raw = environment?.PAPERCLIP_PI_PROVIDERS; + if (raw === undefined) return undefined; + const invalid = () => new Error("Pi custom providers require a bounded JSON object with explicit credentials; command-based credentials are unsupported"); + if (Buffer.byteLength(raw) > 64 * 1024) throw invalid(); + let providers: unknown; + try { providers = JSON.parse(raw); } catch { throw invalid(); } + if (!providers || typeof providers !== "object" || Array.isArray(providers)) throw invalid(); + const names = new Set(); + const credential = (value: unknown) => { + if (typeof value !== "string" || value.trimStart().startsWith("!")) throw invalid(); + if (/^[A-Z][A-Z0-9_]{0,127}$/.test(value)) { + if (/^(?:PAPERCLIP_|NODE_|NPM_|npm_)/.test(value) + || RESERVED_PI_CREDENTIAL_NAMES.has(value)) throw invalid(); + names.add(value); + } + }; + const inspect = (value: unknown, depth = 0): void => { + if (depth > 16) throw invalid(); + if (!value || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { + if (key === "apiKey") credential(child); + else if (key === "headers") { + if (!child || typeof child !== "object" || Array.isArray(child)) throw invalid(); + for (const header of Object.values(child)) credential(header); + } else inspect(child, depth + 1); + } + }; + for (const provider of Object.values(providers)) { + if (!provider || typeof provider !== "object" || Array.isArray(provider)) throw invalid(); + inspect(provider); + } + if (names.size > 64) throw invalid(); + const canonical = (value: unknown): string => value && typeof value === "object" + ? Array.isArray(value) ? `[${value.map(canonical).join(",")}]` + : `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical((value as Record)[key])}`).join(",")}}` + : JSON.stringify(value); + const json = `${canonical({ providers })}\n`; + return { json, digest: `sha256:${createHash("sha256").update(json).digest("hex")}`, credentialNames: [...names] }; +} + +export function piCredentialNames(environment?: NodeJS.ProcessEnv): readonly string[] { + return [...new Set([...PI_CREDENTIAL_NAMES, ...(piProviderConfiguration(environment)?.credentialNames ?? [])])]; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-provision.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-provision.test.ts new file mode 100644 index 0000000000..b9dfcd6868 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-provision.test.ts @@ -0,0 +1,114 @@ +import { mkdtemp, mkdir, readFile, readdir, realpath, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { provisionPackageRoot, provisionPi } from "../../../scripts/provision-pi.mjs"; + +const mocks = vi.hoisted(() => ({ verify: vi.fn(), build: vi.fn(), close: vi.fn(), beforeMkdir: vi.fn() })); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, mkdir: async (...args: Parameters) => { await mocks.beforeMkdir(...args); return actual.mkdir(...args); } }; +}); +vi.mock("./pi-installation.ts", () => ({ verifyPiInstallation: mocks.verify })); +vi.mock("../../../scripts/materialize-pi-distribution.mjs", () => ({ materializePiDistribution: mocks.build })); +const roots: string[] = []; +afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); +beforeEach(() => { + vi.clearAllMocks(); + mocks.beforeMkdir.mockReset(); + mocks.verify.mockImplementation(async () => ({ openCommand: async () => ({ close: mocks.close }) })); + mocks.build.mockImplementation(async ({ outputRoot }: { outputRoot: string }) => { await mkdir(outputRoot, { recursive: true }); await writeFile(join(outputRoot, "owned"), "fixture"); }); +}); +async function fixture(vendored = true) { + const root = await realpath(await mkdtemp(join(tmpdir(), "pi-provision-"))); roots.push(root); + const cli = join(root, vendored ? "dist/vendor/paperclip-runner/cli" : "dist/cli"); + await mkdir(cli, { recursive: true }); + await writeFile(join(root, "package.json"), JSON.stringify({ name: vendored ? "@paperclipai/server" : "@paperclipai/paperclip-runner" })); + const entry = join(cli, "provision-pi.cjs"); await writeFile(entry, "fixture"); + const assetRoot = vendored ? join(root, "dist/vendor/paperclip-runner") : root; + return { root, cli, entry, assetRoot, parent: join(assetRoot, "provider-assets/pi"), output: join(assetRoot, "provider-assets/pi", `${process.platform}-${process.arch}`) }; +} +it("recognizes both published layouts without resolving a private npm package", async () => { + for (const vendored of [true, false]) { const f = await fixture(vendored); expect(await provisionPackageRoot(f.entry)).toMatchObject({ root: f.root, assetRoot: f.assetRoot }); } +}); +it("rejects wrong package, linked entrypoint and escaping asset roots before materialization", async () => { + const f = await fixture(); const other = await fixture(); + await writeFile(join(f.root, "package.json"), '{"name":"foreign"}'); + await expect(provisionPi(f.entry)).rejects.toThrow("identity"); + await writeFile(join(f.root, "package.json"), '{"name":"@paperclipai/server"}'); + await rm(f.entry); await symlink(other.entry, f.entry); + await expect(provisionPi(f.entry)).rejects.toThrow("linked"); + await rm(f.entry); await writeFile(f.entry, "fixture"); await symlink(other.root, join(f.assetRoot, "provider-assets")); + await expect(provisionPi(f.entry)).rejects.toThrow("escapes"); + expect(mocks.build).not.toHaveBeenCalled(); +}); +it("verifies an existing cache in full and never repairs a rejected cache automatically", async () => { + const f = await fixture(); await mkdir(f.output, { recursive: true }); await writeFile(join(f.output, "original"), "retain"); + expect(await provisionPi(f.entry)).toMatchObject({ status: "verified_existing" }); + mocks.verify.mockRejectedValueOnce(new Error("closure differs")); + await expect(provisionPi(f.entry)).rejects.toThrow("closure differs"); + expect(await readFile(join(f.output, "original"), "utf8")).toBe("retain"); + expect(mocks.build).not.toHaveBeenCalled(); expect(await readdir(f.parent)).toEqual([`${process.platform}-${process.arch}`]); +}); +it("publishes only after staging verification, then verifies the actual package authority", async () => { + const f = await fixture(); vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "untrusted-ambient"); + expect(await provisionPi(f.entry)).toMatchObject({ status: "installed_verified" }); + expect(mocks.verify).toHaveBeenCalledTimes(2); expect(mocks.close).toHaveBeenCalledTimes(2); + expect(process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT).toBe("untrusted-ambient"); + expect(await readdir(f.parent)).toEqual([`${process.platform}-${process.arch}`]); +}); +it.each(["build", "staging", "published"])("cleans only its owned transaction after %s failure", async stage => { + const f = await fixture(); + if (stage === "build") mocks.build.mockRejectedValueOnce(new Error("failed")); + if (stage === "staging") mocks.verify.mockRejectedValueOnce(new Error("failed")); + if (stage === "published") mocks.verify.mockResolvedValueOnce({ openCommand: async () => ({ close: mocks.close }) }).mockRejectedValueOnce(new Error("failed")); + await expect(provisionPi(f.entry)).rejects.toThrow("failed"); + expect(await readdir(f.parent)).toEqual([]); +}); +it("refuses a concurrent setup without deleting its lock or launching work", async () => { + const f = await fixture(); await mkdir(f.parent, { recursive: true }); + const name = `.setup-${process.platform}-${process.arch}.lock`; await writeFile(join(f.parent, name), "other"); + await expect(provisionPi(f.entry)).rejects.toThrow(); expect(mocks.build).not.toHaveBeenCalled(); + expect(await readFile(join(f.parent, name), "utf8")).toBe("other"); +}); +it("honors cancellation after owned materialization without publishing or leaving temporary files", async () => { + const f = await fixture(); let cancelled = false; + mocks.build.mockImplementationOnce(async ({ outputRoot }: { outputRoot: string }) => { await mkdir(outputRoot, { recursive: true }); cancelled = true; }); + await expect(provisionPi(f.entry, () => { if (cancelled) throw new Error("cancelled"); })).rejects.toThrow("cancelled"); + expect(await readdir(f.parent)).toEqual([]); +}); + +it("does not replace an empty destination that appears during preparation", async () => { + const f = await fixture(); + mocks.verify.mockImplementationOnce(async () => { await mkdir(f.output); return { openCommand: async () => ({ close: mocks.close }) }; }); + await expect(provisionPi(f.entry)).rejects.toThrow("destination appeared"); + expect(await readdir(f.output)).toEqual([]); +}); +it("retains a replaced staging root while still releasing its own lock", async () => { + const f = await fixture(); + let moved: string | undefined; + mocks.build.mockImplementationOnce(async ({ outputRoot }: { outputRoot: string }) => { + const temporary = outputRoot.slice(0, outputRoot.indexOf("/package/provider-assets/")); + moved = `${temporary}-original`; await rename(temporary, moved); + await mkdir(temporary); await writeFile(join(temporary, "foreign"), "retain"); + throw new Error("materialization failed"); + }); + await expect(provisionPi(f.entry)).rejects.toThrow("cleanup failed"); + const names = await readdir(f.parent); + expect(names.some(name => name.endsWith(".lock"))).toBe(false); + const replaced = names.find(name => !name.endsWith("-original"))!; + expect(await readFile(join(f.parent, replaced, "foreign"), "utf8")).toBe("retain"); + expect(moved).toBeDefined(); // fixture teardown owns both test-created roots +}); + +it("uses exclusive publication when an empty target appears after the absence check", async () => { + const f = await fixture(); + const actual = await vi.importActual("node:fs/promises"); + mocks.beforeMkdir.mockImplementationOnce(() => undefined); + mocks.beforeMkdir.mockImplementation(async (path: unknown) => { + if (path === f.output) { mocks.beforeMkdir.mockReset(); await actual.mkdir(f.output); } + }); + await expect(provisionPi(f.entry)).rejects.toThrow(/EEXIST/); + expect(await readdir(f.output)).toEqual([]); + expect(await readdir(f.parent)).toEqual([`${process.platform}-${process.arch}`]); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.test.ts index c174be6d76..c11999afac 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.test.ts @@ -4,6 +4,7 @@ import { mkdtemp, mkdir, realpath, rename, rm, symlink, writeFile } from "node:f import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import Ajv from "ajv"; import { checkPiNativeTool, installPiRuntimeExtension, piMcpRequest, readPiRuntimeConfiguration, PI_NATIVE_QUESTION_TOOL, PI_PERMISSION_TITLE_PREFIX, type PiExtensionApi, type PiRuntimeConfiguration, type PiToolDefinition, @@ -30,6 +31,43 @@ function harness() { } describe("owned Pi runtime extension", () => { + it("advertises only the fields each native question method accepts", async () => { + const { config } = await workspace(); const h = harness(); + await installPiRuntimeExtension(h.api, config); + const validate = new Ajv().compile(h.nativeTools[0]!.parameters); + const questions = [ + { method: "select", title: "Color", options: [{ id: "blue", label: "Blue" }] }, + { method: "confirm", title: "Preference", message: "Prefer dark mode?" }, + { method: "input", title: "Name", placeholder: "Name" }, + { method: "editor", title: "Draft", prefill: "Old draft" }, + ]; + for (const question of questions) expect(validate(question)).toBe(true); + for (const question of [ + {}, + { ...questions[0], options: JSON.stringify(questions[0]!.options) }, + { ...questions[3], placeholder: "Name" }, + { ...questions[2], prefill: "Old draft" }, + { ...questions[1], options: [] }, + { method: "select", title: "Color" }, + { method: "confirm", title: "Preference" }, + ]) expect(validate(question)).toBe(false); + }); + + it("exposes native question field types to gateways that describe root properties", async () => { + const { config } = await workspace(); const h = harness(); + await installPiRuntimeExtension(h.api, config); + const schema = h.nativeTools[0]!.parameters; + expect(schema.required).toEqual(["method", "title"]); + expect(schema.properties).toMatchObject({ + method: { type: "string", enum: ["select", "confirm", "input", "editor"] }, + title: { type: "string" }, + options: { type: "array", items: { type: "object", required: ["id", "label"] } }, + message: { type: "string" }, + placeholder: { type: "string" }, + prefill: { type: "string" }, + }); + }); + it("stops Pi 1 cache warming even when native economics recommend a paid refresh", async () => { const { config } = await workspace(); const h = harness(); await installPiRuntimeExtension(h.api, config); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts index 8046309437..45ce3866cb 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-runtime-extension.ts @@ -374,11 +374,31 @@ export async function installPiRuntimeExtension( pi.registerTool({ name: PI_NATIVE_QUESTION_TOOL, label: "Ask a native question", description: "Ask the human a native Pi select, confirm, input, or editor question. Select is single-choice and returns the supplied stable option ID. This cannot approve tools or a Paperclip Plan. For durable task questions or Plan approval use the assigned Paperclip semantic tools. Cancellation is not an answer; confirm false means No or dismissal.", + // Model gateways may render only the root properties when describing a + // tool. Expose the field types there as well as in the strict method + // branches, so an array of options cannot be mistaken for JSON text. parameters: { type: "object", additionalProperties: false, required: ["method", "title"], properties: { - method: { type: "string", enum: ["select", "confirm", "input", "editor"] }, title: { type: "string", maxLength: PI_QUESTION_LABEL_MAX_LENGTH }, + method: { type: "string", enum: ["select", "confirm", "input", "editor"] }, + title: { type: "string", maxLength: PI_QUESTION_LABEL_MAX_LENGTH }, options: { type: "array", minItems: 1, maxItems: 128, items: { type: "object", additionalProperties: false, required: ["id", "label"], properties: { id: { type: "string", pattern: "^[A-Za-z0-9_-]{1,128}$" }, label: { type: "string", maxLength: PI_QUESTION_LABEL_MAX_LENGTH } } } }, - message: { type: "string", maxLength: 16384 }, placeholder: { type: "string", maxLength: 16384 }, prefill: { type: "string", maxLength: 16384 }, - } }, + message: { type: "string", maxLength: 16384 }, + placeholder: { type: "string", maxLength: 16384 }, + prefill: { type: "string", maxLength: 16384 }, + }, anyOf: [ + { type: "object", additionalProperties: false, required: ["method", "title", "options"], properties: { + method: { const: "select" }, title: { type: "string", maxLength: PI_QUESTION_LABEL_MAX_LENGTH }, + options: { type: "array", minItems: 1, maxItems: 128, items: { type: "object", additionalProperties: false, required: ["id", "label"], properties: { id: { type: "string", pattern: "^[A-Za-z0-9_-]{1,128}$" }, label: { type: "string", maxLength: PI_QUESTION_LABEL_MAX_LENGTH } } } }, + } }, + { type: "object", additionalProperties: false, required: ["method", "title", "message"], properties: { + method: { const: "confirm" }, title: { type: "string", maxLength: PI_QUESTION_LABEL_MAX_LENGTH }, message: { type: "string", maxLength: 16384 }, + } }, + { type: "object", additionalProperties: false, required: ["method", "title"], properties: { + method: { const: "input" }, title: { type: "string", maxLength: PI_QUESTION_LABEL_MAX_LENGTH }, placeholder: { type: "string", maxLength: 16384 }, + } }, + { type: "object", additionalProperties: false, required: ["method", "title"], properties: { + method: { const: "editor" }, title: { type: "string", maxLength: PI_QUESTION_LABEL_MAX_LENGTH }, prefill: { type: "string", maxLength: 16384 }, + } }, + ] }, async execute(callId, args, signal, _onUpdate, context) { const id = identities.bind(callId, PI_NATIVE_QUESTION_TOOL, args); const previous = questions.get(id); if (previous) return previous; diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-thinking.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-thinking.test.ts new file mode 100644 index 0000000000..999e69be04 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-thinking.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "vitest"; +import { admittedPiThinkingLevel, createPiThinkingAdmission, resolvePiThinkingLevel } from "./pi-thinking.js"; + +const config = (mode: string) => [{ id: "thought_level", type: "select", currentValue: mode, options: ["high", "low", "max"].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: "off" | "low" | "high" | "max", initial: string = expected) { + const admission = createPiThinkingAdmission(expected); const guard = admission.createGuard(); + guard("outbound", { id: 0, method: "session/new", params: {} }); + guard("inbound", { id: 0, result: session(initial) }); + if (initial === expected) { + guard("outbound", { id: 100, method: "session/set_config_option", params: { sessionId: "native", configId: "thought_level", value: expected } }); + guard("inbound", { id: 100, result: { configOptions: config(expected) } }); + } + return { admission, guard }; +} + +describe("Pi thinking native mode admission", () => { + it("requires explicit Pi thinking and rejects unadmitted aliases and other providers", () => { + expect(() => resolvePiThinkingLevel("pi", undefined)).toThrow(/explicit/); + expect(resolvePiThinkingLevel("codex", undefined)).toBeUndefined(); + for (const mode of ["code", "architect", "search", "chat", "", null]) expect(() => resolvePiThinkingLevel("pi", mode)).toThrow(); + expect(() => resolvePiThinkingLevel("cursor", "high")).toThrow(/only supported/); + }); + it.each(["off", "low", "high", "max"] 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.each(["medium", "high"])("allows initial %s/model-selection updates but requires a correlated low setter before prompting", initial => { + const { admission, guard } = opened("low", initial); + guard("inbound", { method: "session/update", params: { sessionId: "native", update: { sessionUpdate: "current_mode_update", currentModeId: "high" } } }); + guard("inbound", { method: "session/update", params: { sessionId: "native", update: { sessionUpdate: "config_option_update", configOptions: config("high") } } }); + expect(admission.isReady()).toBe(false); + guard("outbound", { id: 2, method: "session/set_config_option", params: { sessionId: "native", configId: "thought_level", value: "low" } }); + guard("inbound", { method: "session/update", params: { sessionId: "native", update: { sessionUpdate: "current_mode_update", currentModeId: "low" } } }); + expect(admission.isReady()).toBe(false); + guard("inbound", { id: 2, result: { configOptions: config("low") } }); + admission.assertReady(); guard("outbound", prompt); + }); + it("never admits from an initial matching mode before the explicit setter", () => { + const admission = createPiThinkingAdmission("low"); const guard = admission.createGuard(); + guard("outbound", { id: 0, method: "session/new", params: {} }); + guard("inbound", { id: 0, result: session("low") }); + expect(() => guard("outbound", prompt)).toThrow(/admission/); + }); + it("permits explicit initial configuration and does not use an update as its acknowledgement", () => { + const { admission, guard } = opened("low", "high"); + expect(admission.isReady()).toBe(false); + guard("outbound", { id: 2, method: "session/set_config_option", params: { sessionId: "native", configId: "thought_level", value: "low" } }); + guard("inbound", { method: "session/update", params: { sessionId: "native", update: { sessionUpdate: "current_mode_update", currentModeId: "low" } } }); + expect(admission.isReady()).toBe(false); + guard("inbound", { id: 2, result: { configOptions: config("low") } }); + admission.assertReady(); guard("outbound", prompt); + }); + it.each(["missing", "conflicting", "wrong"])("gates a reloaded prompt with %s mode acknowledgement", kind => { + const { admission } = opened("low"); const guard = admission.createGuard(); + guard("outbound", { id: 2, method: "session/load", params: { sessionId: "native" } }); + const result = kind === "missing" ? {} : kind === "conflicting" ? { ...session("low"), modes: { currentModeId: "max" } } : session("high"); + expect(() => guard("inbound", { id: 2, result })).toThrow(/mode admission/); + expect(() => guard("outbound", prompt)).toThrow(/mode admission/); + }); + it.each([false, true])("cannot repair incompatible restored mode with a later setter, cold restoration=%s", cold => { + const admission = cold ? createPiThinkingAdmission("low", { restoring: true }) : opened("low").admission; + const guard = admission.createGuard(); + guard("outbound", { id: 2, method: "session/load", params: { sessionId: "native" } }); + expect(() => guard("inbound", { id: 2, result: session("high") })).toThrow(/drifted/); + expect(() => guard("outbound", { id: 3, method: "session/set_config_option", params: { sessionId: "native", configId: "thought_level", value: "low" } })).toThrow(/drifted/); + expect(() => guard("outbound", prompt)).toThrow(/drifted/); + }); + it("rejects overlapping configuration controls so one ACK cannot admit another pending mutation", () => { + const { guard, admission } = opened("low"); + guard("outbound", { id: 4, method: "session/set_config_option", params: { sessionId: "native", configId: "model", value: "qualified" } }); + expect(() => guard("outbound", { id: 5, method: "session/set_config_option", params: { sessionId: "native", configId: "thought_level", value: "low" } })).toThrow(/overlapping/); + expect(() => guard("inbound", { id: 4, result: { configOptions: config("low") } })).toThrow(/overlapping/); + expect(admission.assertReady).toThrow(/overlapping/); + }); + it("blocks prompts while admitted configuration is pending and restores only from its exact ACK", () => { + const pending = opened("low"); + pending.guard("outbound", { id: 4, method: "session/set_config_option", params: { sessionId: "native", configId: "model", value: "qualified" } }); + expect(pending.admission.isReady()).toBe(false); + expect(() => pending.guard("outbound", prompt)).toThrow(/admission/); + const awaited = opened("low"); + awaited.guard("outbound", { id: 4, method: "session/set_config_option", params: { sessionId: "native", configId: "model", value: "qualified" } }); + awaited.guard("inbound", { id: 4, result: { configOptions: config("low") } }); + awaited.guard("outbound", prompt); + }); + it("rejects late mode drift and keeps the connection failed closed", () => { + const { admission, guard } = opened("low"); + expect(() => guard("inbound", { method: "session/update", params: { sessionId: "native", update: { sessionUpdate: "current_mode_update", currentModeId: "high" } } })).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 = createPiThinkingAdmission("low"); const g = forged.createGuard(); + g("inbound", { id: 2, result: session("low") }); expect(forged.assertReady).toThrow(); + const { guard } = opened("low", "high"); + guard("outbound", { id: 2, method: "session/set_config_option", params: { sessionId: "native", configId: "thought_level", value: "low" } }); + expect(() => guard("inbound", { id: 2, result: { configOptions: config("high") } })).toThrow(/did not apply/); + const other = opened("low").guard; + expect(() => other("outbound", { id: 3, method: "session/set_config_option", params: { sessionId: "other", configId: "thought_level", value: "low" } })).toThrow(/different session/); + }); + it.each([false, true])("rejects config-option mode drift with active prompt=%s", active => { + const { admission, guard } = opened("low"); + if (active) guard("outbound", prompt); + expect(() => guard("inbound", { method: "session/update", params: { sessionId: "native", update: { sessionUpdate: "config_option_update", configOptions: config("high") } } })).toThrow(/drifted/); + expect(admission.assertReady).toThrow(/drifted/); + }); + it.each(["foreign", "duplicate", "invalid"])("rejects %s mode configuration notifications", kind => { + const { guard } = opened("low"); + const options = kind === "duplicate" ? [...config("low"), ...config("low")] : config(kind === "invalid" ? "architect" : "low"); + 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("low", "high"); + for (const options of [[{ id: "model", currentValue: "fixture" }], config("low")]) { + 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("low", "high"); + guard("outbound", { id: 4, method: "session/set_mode", params: { sessionId: "native", modeId: "low" } }); + guard("inbound", { id: 4, result: {} }); expect(admission.assertReady).toThrow(); + }); +}); + + +// Real ACPX manager/SDK transport, deterministic local child (not the real Pi +// model). The separate pinned-Pi package contract proves its effective wire. +it.each(["valid", "wrong-echo", "unsupported"])("actual ACPX initial model selection then low admission: %s", 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 root = await mkdtemp(join(tmpdir(), "pi-thinking-wire-")); + const children: Array<{ child: ReturnType; closed: Promise }> = []; + const methods: string[] = []; + const admission = createPiThinkingAdmission("low"); + const peer = String.raw` +let mode='medium'; +const send=x=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...x})+'\n'); +const configs=()=>[{id:'thought_level',name:'Thinking',type:'select',currentValue:mode,options:['off','low','medium','high','max'].map(value=>({value,name:value}))},{id:'model',name:'Model',type:'select',currentValue:'qualified',options:[{value:'qualified',name:'Qualified'}]}]; +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:['off','low','medium','high','max'].map(id=>({id,name:id}))},configOptions:configs()}}); + else if(m.method==='session/set_config_option'){ + if(m.params.configId==='model')mode='high'; + else if(m.params.configId==='thought_level'){ + if(process.env.BEHAVIOR==='unsupported')return send({id:m.id,error:{code:-32602,message:'Unsupported level'}}); + if(process.env.BEHAVIOR!=='wrong-echo')mode=m.params.value; + } + send({method:'session/update',params:{sessionId:'native',update:{sessionUpdate:'current_mode_update',currentModeId:mode}}}); + send({method:'session/update',params:{sessionId:'native',update:{sessionUpdate:'config_option_update',configOptions:configs()}}}); + send({id:m.id,result:{configOptions:configs()}}); + } + else if(m.method==='session/prompt')send({id:m.id,result:{stopReason:'end_turn'}}); +});`; + const runtime = createAcpRuntime({ cwd: root, agentRegistry: createAgentRegistry({ overrides: { pi: "fixture" } }), sessionStore: createRuntimeStore({ stateDir: join(root, "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: root, env: { BEHAVIOR: behavior }, stdio: ["pipe", "pipe", "pipe"] }); + const closed = new Promise(resolve => child.once("close", () => resolve())); + child.on("error", () => {}); children.push({ child, closed }); return child; + }, + }); + let handle; + try { + handle = await runtime.ensureSession({ sessionKey: "pi-mode", agent: "pi", mode: "persistent", cwd: root, sessionOptions: { model: "qualified" } }); + expect(admission.isReady()).toBe(false); + await runtime.setConfigOption({ handle, key: "model", value: "qualified" }); + expect(admission.isReady()).toBe(false); + const selecting = runtime.setConfigOption({ handle, key: "thought_level", value: "low" }); + if (behavior !== "valid") { await expect(selecting).rejects.toThrow(); expect(methods).not.toContain("session/prompt"); return; } + await selecting; admission.assertReady(); + const turn = runtime.startTurn({ handle, text: "fixture", mode: "prompt", requestId: "once", 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(method => method === "session/prompt")).toHaveLength(1); + expect(methods.filter(method => method === "session/set_config_option")).toHaveLength(2); + } finally { + try { if (handle) await runtime.close({ handle, reason: "fixture cleanup" }); } + finally { + for (const { child } of children) if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + await Promise.allSettled(children.map(({ closed }) => closed)); + await rm(root, { recursive: true, force: true }); + } + } +}); + +it("publishes only independently matching observed Pi mode, never the requested value alone", () => { + expect(admittedPiThinkingLevel("pi", "low", { piThinkingLevel: "low" }, { piThinkingLevel: "low" })).toBe("low"); + for (const observed of [undefined, "high", "medium", null]) { + expect(() => admittedPiThinkingLevel("pi", "low", { piThinkingLevel: "low" }, { piThinkingLevel: observed })).toThrow(); + expect(() => admittedPiThinkingLevel("pi", "low", { piThinkingLevel: observed }, { piThinkingLevel: "low" })).toThrow(); + } + expect(admittedPiThinkingLevel("codex", undefined, {}, {})).toBeUndefined(); + expect(() => admittedPiThinkingLevel("codex", undefined, {}, { piThinkingLevel: "low" })).toThrow(); +}); + +it("preserves effective Pi mode through the persisted harness identity projection", async () => { + const { parseProviderIdentity } = await import("../codex/codex-driver-values.js"); + const value = { kind: "acpx", normalizedSessionId: "session", acpxRecordId: "record", backendSessionId: "backend", agentSessionId: "agent", profileDigest: "sha256:profile", workspaceDigest: "sha256:workspace", requestedModel: "model", effectiveModel: "model", piThinkingLevel: "low", providerLifetimeFenceCandidates: [60001, 60002, 60003] }; + expect(parseProviderIdentity(value)).toMatchObject({ piThinkingLevel: "low" }); + expect(() => parseProviderIdentity({ ...value, piThinkingLevel: "medium" })).toThrow(); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-thinking.ts b/packages/paperclip-runner/src/drivers/acpx/pi-thinking.ts new file mode 100644 index 0000000000..aa5d67cde3 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/pi-thinking.ts @@ -0,0 +1,129 @@ +export type PiThinkingLevel = "off" | "low" | "high" | "max"; + +export function resolvePiThinkingLevel(agent: string, mode: unknown): PiThinkingLevel | undefined { + if (agent !== "pi") { + if (mode !== undefined) throw new Error("Pi thinking level is only supported by Pi"); + return undefined; + } + if (mode === undefined) throw new Error("Pi thinking level must be explicit"); + if (mode !== "off" && mode !== "low" && mode !== "high" && mode !== "max") throw new Error("Invalid Pi thinking level"); + 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 createPiThinkingAdmission(expected: PiThinkingLevel, options: { restoring?: boolean } = {}) { + type State = { sessionId: string | null; mode: string | null; error: Error | null }; + let current: State | null = null; + let selectedOnce = options.restoring === true; + const failure = (detail: string) => new Error(`Pi thinking mode admission failed: ${detail}`); + const assertReady = () => { + if (current?.error) throw current.error; + if (!selectedOnce || !current?.sessionId || current.mode !== expected) throw failure("native mode does not acknowledge the selected mode"); + }; + const readMode = (result: Record, session: boolean): string => { + const configs = Array.isArray(result.configOptions) ? result.configOptions.filter(value => object(value).id === "thought_level") : []; + if (configs.length !== 1) throw failure("native mode configuration is missing or ambiguous"); + const mode = object(configs[0]).currentValue; + // Before exact model selection, Pi can legitimately report a different + // native model's level. Observation alone never admits the first prompt. + if (typeof mode !== "string" || !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(mode)) 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 selectedOnce && 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 (pending.size) throw failure("overlapping native admission controls"); + 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 === "thought_level" && params.value !== expected) throw failure("configuration changes the selected mode"); + state.mode = null; // No prompt may race unacknowledged native configuration. + } + pending.set(message.id, { method, sessionId: params.sessionId, selectsMode: params.configId === "thought_level" }); + } 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 === "thought_level")) { + if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session"); + const observed = readMode(update, false); + if (selectedOnce && observed !== 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 (selectedOnce && 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 && selectedOnce && mode !== expected) throw failure("restored native mode drifted from the selected mode"); + if (!session && (request.selectsMode || selectedOnce) && mode !== expected) throw failure("configuration did not apply the selected mode"); + if (!session && request.selectsMode) selectedOnce = true; + 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; } +} + +/** Cross-check independently returned durable identity and public descriptor. */ +export function admittedPiThinkingLevel(agent: string, requested: unknown, descriptor: unknown, identity: unknown): PiThinkingLevel | undefined { + const expected = resolvePiThinkingLevel(agent, requested); + const reported = object(identity).piThinkingLevel; + const projected = object(descriptor).piThinkingLevel; + if (agent !== "pi") { + if (reported !== undefined || projected !== undefined) throw new Error("Non-Pi identity contains a Pi thinking level"); + return undefined; + } + const effective = resolvePiThinkingLevel("pi", reported); + if (effective !== expected || projected !== effective) throw new Error("Pi effective thinking level differs from the admitted configuration"); + return effective; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-activity.ts b/packages/paperclip-runner/src/drivers/acpx/profile-activity.ts index e367cde626..294e392f0a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-activity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-activity.ts @@ -1,3 +1,4 @@ +import { createCopilotToolEvidence } from "./copilot-tool-evidence.js"; import { cursorActivityAdapter } from "./cursor-activity.js"; import type { CanonicalProviderEvent } from "../../provider-events.js"; import type { AcpxExtensionInput } from "./profile-extensions.js"; @@ -27,6 +28,7 @@ export interface AcpxActivityAdapter { const adapters: Readonly> = { cursor: cursorActivityAdapter, + copilot: { createToolEvidence: createCopilotToolEvidence }, }; const noActivity: AcpxActivityAdapter = Object.freeze({}); diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts index 371ab04127..9ac4d51f87 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts @@ -1,17 +1,32 @@ import { describe, expect, it } from "vitest"; +import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js"; import { isSupportedAcpxProfileVersion } from "./profile-compatibility.js"; describe("historical ACPX profile decoding", () => { it("retains each provider's existing revision boundary", () => { - expect(isSupportedAcpxProfileVersion("cursor", 14)).toBe(true); - for (const agent of ["pi", "claude", "codex", "grok", "copilot"]) { + expect(isSupportedAcpxProfileVersion("pi", 17)).toBe(true); + expect(isSupportedAcpxProfileVersion("pi", 18)).toBe(true); + expect(isSupportedAcpxProfileVersion("cursor", 15)).toBe(true); + expect(isSupportedAcpxProfileVersion("cursor", 16)).toBe(false); + for (const agent of ["pi", "copilot"]) { + expect(isSupportedAcpxProfileVersion(agent, 16)).toBe(true); + const current = QUALIFIED_ACPX_PROFILE_DATA[agent as "pi" | "copilot"].agentProfileVersion; + expect(isSupportedAcpxProfileVersion(agent, current)).toBe(true); + expect(isSupportedAcpxProfileVersion(agent, Math.max(16, current) + 1)).toBe(false); + } + for (const agent of ["claude", "codex", "grok"]) { expect(isSupportedAcpxProfileVersion(agent, 5)).toBe(true); expect(isSupportedAcpxProfileVersion(agent, 6)).toBe(false); } }); - it.each([0, 15, 1.5, "11", null, undefined, NaN])("rejects invalid revisions: %s", version => { + it.each([0, 1.5, "11", null, undefined, NaN])("rejects invalid revisions: %s", version => { expect(isSupportedAcpxProfileVersion("cursor", version)).toBe(false); }); + it("decodes every current release while retaining exact launch admission separately", () => { + for (const [agent, current] of Object.entries(QUALIFIED_ACPX_PROFILE_DATA)) { + expect(isSupportedAcpxProfileVersion(agent, current.agentProfileVersion)).toBe(true); + } + }); it.each(["unknown", "toString", "__proto__"])("rejects unregistered providers: %s", agent => { expect(isSupportedAcpxProfileVersion(agent, 1)).toBe(false); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts index 8642604090..46f14dae81 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts @@ -1,13 +1,20 @@ +import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js"; + /** Decodable historical profile revisions, not permission to launch them. * Exact current profile/package/digest admission is checked separately. */ -export type AcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14; -const historicalVersions: Readonly> = { - pi: [1, 2, 3, 4, 5], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], - grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5], - cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], +type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19; +type CurrentAcpxProfileVersion = typeof QUALIFIED_ACPX_PROFILE_DATA[keyof typeof QUALIFIED_ACPX_PROFILE_DATA]["agentProfileVersion"]; +export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfileVersion; + +const historicalVersions: Readonly> = { + pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], + grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], }; export function isSupportedAcpxProfileVersion(agent: string, value: unknown): value is AcpxProfileVersion { - return Object.hasOwn(historicalVersions, agent) - && historicalVersions[agent]!.includes(value as AcpxProfileVersion); + if (!Object.hasOwn(QUALIFIED_ACPX_PROFILE_DATA, agent)) return false; + const current = QUALIFIED_ACPX_PROFILE_DATA[agent as keyof typeof QUALIFIED_ACPX_PROFILE_DATA]; + return value === current.agentProfileVersion + || historicalVersions[agent]?.includes(value as HistoricalAcpxProfileVersion) === true; } diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts b/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts index bf87e73f70..cce795037a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts @@ -1,3 +1,6 @@ +import { createCopilotProfileExtensionAdapter } from "./copilot-extension-adapter.js"; +import { createPiProfileExtensionAdapter } from "./pi-extension-adapter.js"; +import { COPILOT_ACP_CLIENT_CAPABILITIES } from "./copilot-events.js"; 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"; @@ -38,10 +41,13 @@ export function createAcpxProfileExtensionAdapter( context: AcpxProfileExtensionContext, ): AcpxProfileExtensionAdapter | null { if (agent === "cursor") return createCursorProfileExtensionAdapter(context); + if (agent === "copilot") return createCopilotProfileExtensionAdapter(context); + if (agent === "pi") return createPiProfileExtensionAdapter(context); return null; } export function acpxProfileClientCapabilities(agent: QualifiedAcpxAgent): Record { if (agent === "cursor") return structuredClone(CURSOR_CLIENT_CAPABILITIES); + if (agent === "copilot") return structuredClone(COPILOT_ACP_CLIENT_CAPABILITIES); return {}; } diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts index a4725a425d..0849c3fb79 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-installation.ts @@ -1,10 +1,15 @@ import { verifyCursorInstallation } from "./cursor-installation.js"; import { assertCursorWorkspacePolicy } from "./cursor-launch-policy.js"; import { resolveQualifiedAcpxProfile, type QualifiedAcpxAgent, type QualifiedAcpxProfile } from "./qualified-profiles.js"; +import { verifyPiInstallation } from "./pi-installation.js"; +import { assertCopilotCredentials, classifyCopilotFailure } from "./copilot-profile.js"; +import { verifyCopilotInstallation } from "./copilot-installation.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 === "pi") return verifyPiInstallation(profile); + if (profile.agent === "copilot") return verifyCopilotInstallation(profile); if (profile.agent === "cursor") { try { return await verifyCursorInstallation(profile); } catch (error) { @@ -25,6 +30,7 @@ export async function assertAcpxProfileWorkspace(agent: QualifiedAcpxAgent, work /** Called with the sanitized launch environment, never ambient process.env. */ export function assertAcpxProfileEnvironment(agent: QualifiedAcpxAgent, environment: Readonly): void { + if (agent === "copilot") assertCopilotCredentials(environment); 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 }); } @@ -32,6 +38,11 @@ export function assertAcpxProfileEnvironment(agent: QualifiedAcpxAgent, environm /** Provider admission diagnostics expose no raw provider strings or credentials. */ export function classifyAcpxProfileError(agent: QualifiedAcpxAgent, error: unknown): Error | null { + if (agent === "copilot") { + const failure = classifyCopilotFailure(error); + if (failure.code === "COPILOT_REQUEST_FAILED") return null; + return Object.assign(new Error(failure.message), { code: failure.code, retryable: false }); + } if (agent !== "cursor" || !(error instanceof Error)) return null; const message = error.message; let code: string; 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 600ca2111a..a083a41264 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 @@ -7,7 +7,7 @@ import { resolveRunnerProviderAssetsRoot } from "./provider-assets-root.js"; const roots: string[] = []; afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); -async function root() { const path = await mkdtemp(join(tmpdir(), "runner-provider-assets-")); roots.push(path); return path; } +async function root() { const path = await realpath(await mkdtemp(join(tmpdir(), "runner-provider-assets-"))); roots.push(path); return path; } it("uses one fixed provider directory for source, compiled and bundled runner layouts", async () => { vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", undefined); @@ -34,7 +34,8 @@ it("resolves descriptor sidecars only through the runner-bound canonical package 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 mkdir(join(vendored, "cli"), { recursive: true }); + await writeFile(join(vendored, "cli/acpx-runtime-sidecar.cjs"), "// bundled sidecar"); 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")); @@ -53,7 +54,7 @@ it("rejects a server bundle redirected outside its published package", async () 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"); + expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "cursor")).toThrow("escaped"); }); it("rejects external manifests, links, asset escapes and incomplete authority", async () => { @@ -70,3 +71,40 @@ it("rejects external manifests, links, asset escapes and incomplete authority", vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", undefined); expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "copilot")).toThrow("no bound"); }); + +async function serverFixture() { + const path = await root(); + await writeFile(join(path, "package.json"), JSON.stringify({ name: "@paperclipai/server" })); + await mkdir(join(path, "dist/vendor/paperclip-runner/cli"), { recursive: true }); + await writeFile(join(path, "dist/vendor/paperclip-runner/cli/acpx-runtime-sidecar.cjs"), "// bundled sidecar"); + return path; +} +it("admits the public server's exact vendored layout for readiness and descriptor execution", async () => { + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", undefined); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", undefined); + const path = await serverFixture(); + const url = pathToFileURL(join(path, "dist/vendor/paperclip-runner/drivers/acpx/pi-installation.js")).href; + expect(resolveRunnerProviderAssetsRoot(url, "pi")).toBe(join(path, "dist/vendor/paperclip-runner/provider-assets/pi")); + 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", "pi")).toBe(join(path, "dist/vendor/paperclip-runner/provider-assets/pi")); +}); +it("rejects an unrelated server manifest, escaped vendor sidecar and linked manifest", async () => { + const path = await serverFixture(); const outside = await root(); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", path); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(path, "package.json")); + await mkdir(join(path, "nested")); + await writeFile(join(path, "nested/package.json"), JSON.stringify({ name: "@paperclipai/server" })); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(path, "nested/package.json")); + expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "pi")).toThrow("outside its package root"); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", join(path, "package.json")); + await rm(join(path, "dist/vendor/paperclip-runner/cli"), { recursive: true }); + await symlink(outside, join(path, "dist/vendor/paperclip-runner/cli")); + expect(() => resolveRunnerProviderAssetsRoot("file:///proc/self/fd/18", "pi")).toThrow("escaped"); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", undefined); + vi.stubEnv("PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", undefined); + await rm(join(path, "package.json")); + await writeFile(join(outside, "package.json"), JSON.stringify({ name: "@paperclipai/server" })); + await symlink(join(outside, "package.json"), join(path, "package.json")); + expect(() => resolveRunnerProviderAssetsRoot(pathToFileURL(join(path, "dist/vendor/paperclip-runner/drivers/acpx/pi-installation.js")).href, "pi")).toThrow(); +}); 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 e37116d75a..2eed816169 100644 --- a/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts +++ b/packages/paperclip-runner/src/drivers/acpx/provider-assets-root.ts @@ -4,6 +4,16 @@ import { fileURLToPath } from "node:url"; type NativeProvider = "cursor" | "copilot" | "pi"; const RUNNER_PACKAGE_NAME = "@paperclipai/paperclip-runner"; +const SERVER_PACKAGE_NAME = "@paperclipai/server"; +function assertServerVendorLayout(root: string): void { + const sidecar = join(root, "dist/vendor/paperclip-runner/cli/acpx-runtime-sidecar.cjs"); + for (const part of ["dist", "dist/vendor", "dist/vendor/paperclip-runner", "dist/vendor/paperclip-runner/cli"]) { + const path = join(root, part); const info = lstatSync(path); + if (!info.isDirectory() || info.isSymbolicLink() || realpathSync(path) !== path) throw new Error("Runner server vendor layout escaped its package"); + } + const info = lstatSync(sidecar); + if (!info.isFile() || info.isSymbolicLink() || realpathSync(sidecar) !== sidecar) throw new Error("Runner server vendor sidecar is invalid"); +} /** Resolve only runner-owned package assets, including descriptor-loaded sidecars. */ export function resolveRunnerProviderAssetsRoot(moduleUrl: string, provider: NativeProvider): string { @@ -28,7 +38,9 @@ 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 (canonicalManifest !== join(packageRoot, "package.json")) throw new Error("Runner provider manifest is outside its package root"); if (value?.name === "@paperclipai/server") { + assertServerVendorLayout(packageRoot); // runnerd derives this binding from the verified sidecar in the public // server package. Images may carry assets alongside that bundle; local // explicit setup uses the OS-account cache when package assets are absent. @@ -42,7 +54,14 @@ export function resolveRunnerProviderAssetsRoot(moduleUrl: string, provider: Nat 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)); + if (new RegExp(`/dist/vendor/paperclip-runner/drivers/acpx/${provider}-installation\\.js$`).test(url.pathname)) { + packageRoot = realpathSync(fileURLToPath(new URL("../../../../../", url))); + const manifest = join(packageRoot, "package.json"); + const metadata = readPackageManifest(manifest, manifest); + if (metadata.name !== SERVER_PACKAGE_NAME) throw new Error("Runner server vendor package identity is invalid"); + assertServerVendorLayout(packageRoot); + packageRoot = join(packageRoot, "dist/vendor/paperclip-runner"); + } else 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)); @@ -71,3 +90,18 @@ function inside(root: string, path: string): boolean { const rel = relative(root, path); return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel); } + +function readPackageManifest(manifest: string, canonicalManifest: string): { name?: unknown } { + const fd = openSync(manifest, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = fstatSync(fd, { bigint: true }); + if (!before.isFile() || before.size < 1n || before.size > 64n * 1024n) throw new Error("Runner provider manifest is not a bounded regular file"); + const bytes = readFileSync(fd); + const after = fstatSync(fd, { bigint: true }); + const named = lstatSync(manifest, { bigint: true }); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs + || named.dev !== before.dev || named.ino !== before.ino || named.isSymbolicLink() + || bytes.length !== Number(before.size) || realpathSync(manifest) !== canonicalManifest) throw new Error("Runner provider manifest changed during admission"); + return JSON.parse(bytes.toString("utf8")) as { name?: unknown }; + } finally { closeSync(fd); } +} diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts index 4ef1f76a81..55f1446263 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts @@ -1,6 +1,4 @@ import { describe, expect, it } from "vitest"; -import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; import { QUALIFIED_ACPX_PROFILES, @@ -45,27 +43,3 @@ describe("qualified ACPX profiles", () => { }); }); }); - -it("binds Cursor v10 to native instructions, tool identity, closures and the exact ACPX guard patch", () => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/cursor-acp/profile-v10-identity.json", import.meta.url), "utf8")); - const distribution = JSON.parse(readFileSync(new URL("../../../cursor-distributions.json", import.meta.url), "utf8")); - expect(identity.declaration.distribution).toEqual(distribution); - expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); - expect(identity.declaration.nativePlanToolIdentity).toBe("request-item-id-bound-lifecycle-v1"); - expect(identity.declaration.nativePermissionToolIdentity).toBe("opaque-native-tool-id-sha256-v1"); - expect(identity.declaration.sessionModeAdmission).toBe("native-config-ack-recovery-bound-v1"); - expect(identity.declaration.sessionModes).toEqual(["agent", "plan", "ask"]); - expect(identity.declaration.defaultSessionMode).toBe("agent"); - expect(identity.declaration.agentProfileVersion).toBe(QUALIFIED_ACPX_PROFILES.cursor.agentProfileVersion); - expect(identity.commandDigest).toBe(QUALIFIED_ACPX_PROFILES.cursor.commandDigest); - expect(identity.commandDigest).toBe(`sha256:${createHash("sha256").update(JSON.stringify(identity.declaration)).digest("hex")}`); - const patch = readFileSync(new URL("../../../../../patches/acpx@0.13.1.patch", import.meta.url)); - expect(identity.declaration.acpxPatchSha256).toBe(createHash("sha256").update(patch).digest("hex")); - for (const [path, field] of [ - ["cursor-plan-tool-identity.ts", "toolIdentitySourceSha256"], - ["acp-permission-adapter.ts", "permissionAdapterSourceSha256"], - ["cursor-tool-evidence.ts", "toolEvidenceSourceSha256"], - ]) { - expect(identity.declaration[field!]).toBe(createHash("sha256").update(readFileSync(new URL(path!, import.meta.url))).digest("hex")); - } -}); diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts index 7e7d8b9a89..ddd5d29314 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts @@ -26,22 +26,69 @@ afterEach(async () => { }); describe("ACPX recovery identity", () => { - it("preserves the pre-manifest Cursor recovery profile identity", async () => { + it.each([ + ["cursor", "../../../test/fixtures/cursor-acp/profile-v10-identity.json"], + ["copilot", "../../../test/fixtures/copilot-profile-v14-identity.json"], + ["pi", "../../../test-fixtures/pi-acp/profile-v13-identity.json"], + ["pi", "../../../test-fixtures/pi-acp/profile-v14-identity.json"], + ["cursor", "../../../test/fixtures/cursor-acp/profile-v7-identity.json"], + ["cursor", "../../../test/fixtures/cursor-acp/profile-v8-identity.json"], + ["cursor", "../../../test/fixtures/cursor-acp/profile-v9-identity.json"], + ["copilot", "../../../test/fixtures/copilot-profile-v7-identity.json"], + ["copilot", "../../../test/fixtures/copilot-profile-v8-identity.json"], + ["copilot", "../../../test/fixtures/copilot-profile-v9-identity.json"], + ["copilot", "../../../test/fixtures/copilot-profile-v10-identity.json"], + ["copilot", "../../../test/fixtures/copilot-profile-v11-identity.json"], + ["copilot", "../../../test/fixtures/copilot-profile-v12-identity.json"], + ["pi", "../../../test-fixtures/pi-acp/profile-v9-identity.json"], + ] as const)("rejects retained %s sessions after the execution identity changes", async (agent, path) => { const fixture = await recoveryFixture(); - const historical = JSON.parse(await readFile(new URL("../../../test/fixtures/cursor-acp/pre-manifest-recovery-identity.json", import.meta.url), "utf8")); - const requestedModel = historical.profile.qualificationModel; - const binding = await createAcpxRecoveryBinding({ - ...fixture.input, - requestedModel, - profile: historical.profile, - }); - const current = await createAcpxRecoveryBinding({ - ...fixture.input, requestedModel, profile: resolveQualifiedAcpxProfile("cursor", requestedModel), - }); - expect(current.profileDigest).not.toBe(binding.profileDigest); - // Captured from e75fde6098b0ddd8cec765bfb6ecaeecb88a26a6 before - // consolidating release declarations; this is historical evidence. - expect(binding.profileDigest).toBe(historical.profileDigest); + const historical = JSON.parse(await readFile(new URL(path, import.meta.url), "utf8")); + const current = resolveQualifiedAcpxProfile(agent, agent === "pi" ? "openrouter/deepseek/deepseek-v4-flash-0731" : fixture.input.requestedModel); + const input = { ...fixture.input, requestedModel: current.qualificationModel, profile: current, ...(agent === "pi" ? { piThinkingLevel: "low" as const } : {}), ...(agent === "cursor" ? { mode: "agent" } : {}) }; + const next = await createAcpxRecoveryBinding(input); + const prior = await createAcpxRecoveryBinding({ ...input, profile: { ...current, + agentProfileVersion: historical.declaration.agentProfileVersion, commandDigest: historical.commandDigest } }); + const priorExpected = { ...fixture.expected, profileDigest: prior.commandDigest, + requestedModel: prior.requestedModel, effectiveModel: prior.effectiveModel, + ...(agent === "cursor" ? { mode: "agent" as const } : {}), ...(agent === "pi" ? { piThinkingLevel: "low" as const } : {}) }; + const record = createAcpxIdentityRecord(priorExpected, prior); + expect(next.profileDigest).not.toBe(prior.profileDigest); + expect(next.profileSessionKey).not.toBe(prior.profileSessionKey); + expect(() => verifyExpectedAcpxIdentity({ ...priorExpected, profileDigest: next.commandDigest }, next, record)).toThrow(/persisted runtime record/); + }); + + it("binds Cursor mode on recovery and rejects missing or changed persisted mode", async () => { + const fixture = await recoveryFixture(); + const input = { ...fixture.input, profile: resolveQualifiedAcpxProfile("cursor", fixture.input.requestedModel), mode: "agent" }; + const agent = await createAcpxRecoveryBinding(input); + const plan = await createAcpxRecoveryBinding({ ...input, mode: "plan" }); + expect(agent.mode).toBe("agent"); + expect(plan.profileSessionKey).not.toBe(agent.profileSessionKey); + const expected = { ...fixture.expected, profileDigest: plan.commandDigest, mode: "plan" as const }; + const record = createAcpxIdentityRecord(expected, plan); + expect(acpxProviderSessionIdentity(record, plan).mode).toBe("plan"); + expect(() => verifyExpectedAcpxIdentity({ ...expected, mode: undefined }, plan, record)).toThrow(/immutable session/); + expect(() => verifyExpectedAcpxIdentity(expected, agent, record)).toThrow(/immutable session/); + expect(() => verifyExpectedAcpxIdentity(expected, plan, { ...record, mode: undefined })).toThrow(/persisted runtime record/); + const customMode = await createAcpxRecoveryBinding({ ...fixture.input, mode: "provider-custom-mode" }); + expect(customMode.mode).toBe("provider-custom-mode"); + }); + + it("binds Pi thinking mode and rejects old or mismatched warm identities", async () => { + const fixture = await recoveryFixture(); + const profile = resolveQualifiedAcpxProfile("pi", "openrouter/deepseek/deepseek-v4-flash-0731"); + const input = { ...fixture.input, profile, requestedModel: profile.qualificationModel, piThinkingLevel: "low" as const }; + const low = await createAcpxRecoveryBinding(input); + const high = await createAcpxRecoveryBinding({ ...input, piThinkingLevel: "high" }); + expect(low.profileSessionKey).not.toBe(high.profileSessionKey); + const expected = { ...fixture.expected, profileDigest: low.commandDigest, requestedModel: low.requestedModel, effectiveModel: low.effectiveModel, piThinkingLevel: "low" as const }; + const persisted = createAcpxIdentityRecord(expected, low); + expect(acpxProviderSessionIdentity(persisted, low).piThinkingLevel).toBe("low"); + expect(() => verifyExpectedAcpxIdentity({ ...expected, piThinkingLevel: undefined }, low, persisted)).toThrow(/immutable/); + expect(() => verifyExpectedAcpxIdentity(expected, low, { ...persisted, piThinkingLevel: undefined })).toThrow(/persisted/); + expect(() => verifyExpectedAcpxIdentity(expected, high, persisted)).toThrow(/immutable/); + await expect(createAcpxRecoveryBinding({ ...input, piThinkingLevel: undefined })).rejects.toThrow(/explicit/); }); it("derives one stable, filesystem-safe runtime directory name", () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts index 4e2faa18f9..4439b02edc 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts @@ -1,4 +1,5 @@ import { parseProviderMode } from "../../contracts/provider-mode.js"; +import { resolvePiThinkingLevel, type PiThinkingLevel } from "./pi-thinking.js"; import { createHash } from "node:crypto"; import { realpath, stat } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; @@ -21,6 +22,7 @@ export interface AcpxRecoveryBinding { effectiveModel: string; permissionMode: NativeAcpxPermissionMode; mode?: string; + piThinkingLevel?: PiThinkingLevel; profileSessionKey: string; } @@ -36,6 +38,7 @@ export interface AcpxIdentityRecord { effectiveModel: string; permissionMode: NativeAcpxPermissionMode; mode?: string; + piThinkingLevel?: PiThinkingLevel; providerLifetimeFenceCandidates: readonly [number, number, number]; } @@ -47,10 +50,13 @@ export async function createAcpxRecoveryBinding(input: { requestedModel: string; permissionMode: NativeAcpxPermissionMode; mode?: string; + piThinkingLevel?: PiThinkingLevel; + providerConfigurationDigest?: string; providerPolicy?: { readOnly: boolean; readRoots?: readonly string[]; protectedPaths?: readonly string[] }; }): Promise { validateIdentity(input.normalizedSessionId, "normalized session"); const mode = parseProviderMode(input.mode); + const piThinkingLevel = resolvePiThinkingLevel(input.profile.agent, input.piThinkingLevel); 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 model does not match its admitted profile"); @@ -58,6 +64,7 @@ export async function createAcpxRecoveryBinding(input: { if (!isDigest(input.profile.commandDigest)) { throw new Error("ACPX recovery profile command digest is invalid"); } + if (input.providerConfigurationDigest !== undefined && !isDigest(input.providerConfigurationDigest)) throw new Error("Invalid provider configuration digest"); const workspacePath = await resolveWorkspace(input.workingDirectory); const workspaceDigest = digest(workspacePath); const runtimeRoot = await resolveAcpxRuntimeRoot( @@ -79,6 +86,7 @@ export async function createAcpxRecoveryBinding(input: { qualificationModel: input.profile.qualificationModel, reportedModelId: input.profile.reportedModelId, permissionPolicy: input.profile.permissionPolicy, + ...(input.providerConfigurationDigest === undefined ? {} : { providerConfigurationDigest: input.providerConfigurationDigest }), ...(input.providerPolicy === undefined ? {} : { executionPolicy: { readOnly: input.providerPolicy.readOnly, readRoots: input.providerPolicy.readRoots ?? [], @@ -95,6 +103,7 @@ export async function createAcpxRecoveryBinding(input: { profileDigest, permissionMode: input.permissionMode, ...(mode ? { mode } : {}), + ...(piThinkingLevel ? { piThinkingLevel } : {}), }), ).replace("sha256:", "paperclip-"); return { @@ -108,6 +117,7 @@ export async function createAcpxRecoveryBinding(input: { effectiveModel: input.requestedModel, permissionMode: input.permissionMode, ...(mode ? { mode } : {}), + ...(piThinkingLevel ? { piThinkingLevel } : {}), profileSessionKey, }; } @@ -129,6 +139,7 @@ export function createAcpxIdentityRecord( effectiveModel: binding.effectiveModel, permissionMode: binding.permissionMode, ...(binding.mode ? { mode: binding.mode } : {}), + ...(binding.piThinkingLevel ? { piThinkingLevel: binding.piThinkingLevel } : {}), providerLifetimeFenceCandidates: Object.freeze([ ...expected.providerLifetimeFenceCandidates, ]) as readonly [number, number, number], @@ -155,6 +166,7 @@ export function acpxProviderSessionIdentity( effectiveModel: record.effectiveModel, permissionMode: record.permissionMode, ...(record.mode ? { mode: record.mode } : {}), + ...(record.piThinkingLevel ? { piThinkingLevel: record.piThinkingLevel } : {}), providerLifetimeFenceCandidates: record.providerLifetimeFenceCandidates, }; verifyExpectedAcpxIdentity(identity, binding, record); @@ -180,7 +192,8 @@ export function verifyExpectedAcpxIdentity( expected.requestedModel !== binding.requestedModel || expected.effectiveModel !== binding.effectiveModel || expected.permissionMode !== binding.permissionMode || - expected.mode !== binding.mode + expected.mode !== binding.mode || + expected.piThinkingLevel !== binding.piThinkingLevel ) { throw new Error( "ACPX recovery identity conflicts with the immutable session configuration", @@ -200,6 +213,7 @@ export function verifyExpectedAcpxIdentity( record.effectiveModel !== binding.effectiveModel || record.permissionMode !== binding.permissionMode || record.mode !== binding.mode || + record.piThinkingLevel !== binding.piThinkingLevel || !sameFenceCandidates( record.providerLifetimeFenceCandidates, expected.providerLifetimeFenceCandidates, @@ -225,6 +239,7 @@ function parsePersistedRecord(value: unknown): AcpxIdentityRecord { "effectiveModel", "permissionMode", "mode", + "piThinkingLevel", "providerLifetimeFenceCandidates", ]); return validatedRecord(record); @@ -252,6 +267,7 @@ function validatedRecord(value: Record): AcpxIdentityRecord { throw new Error("ACPX identity permission mode is invalid"); } if (value.mode !== undefined) parseProviderMode(value.mode); + if (value.piThinkingLevel !== undefined) resolvePiThinkingLevel("pi", value.piThinkingLevel); validateFenceCandidates(value.providerLifetimeFenceCandidates); return value as unknown as AcpxIdentityRecord; } @@ -281,6 +297,7 @@ function validateExpected(expected: AcpxExpectedSessionIdentity): void { throw new Error("Expected ACPX permission mode is invalid"); } if (expected.mode !== undefined) parseProviderMode(expected.mode); + if (expected.piThinkingLevel !== undefined) resolvePiThinkingLevel("pi", expected.piThinkingLevel); 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 950047adcf..2459dab276 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -1,8 +1,10 @@ +import { execFileSync } from "node:child_process"; +import { createPiLaunchSpec } from "./pi-acp-runtime.js"; import { createCopilotToolEvidence } from "./copilot-tool-evidence.js"; import { readNativeSemanticReceipt, type SemanticToolResult } from "../semantic-tool-receipt.js"; import { validateAcpxRichEvent } from "./profile-extensions.js"; import type { CanonicalProviderEvent } from "../../provider-events.js"; -import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -868,9 +870,9 @@ describe("ACPX runtime host", () => { expect(options.launchEnvironment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS).toBe("Bound instructions"); expect(JSON.parse(options.launchEnvironment.PAPERCLIP_PI_READ_ROOTS!)).toEqual([]); expect(options.permissionMode).toBe("approve-all"); - return runtimePort({ getStatus: async () => ({ models: { currentModelId: model } }) }); + return runtimePort({ getStatus: async () => ({ models: { currentModelId: model } }), identity: async () => ({ acpxRecordId: "record-1", backendSessionId: "backend-1", agentSessionId: "agent-1", piThinkingLevel: "low" }) }); }); - const options = { ...fixture.options, agent: "pi" as const, model, + const options = { ...fixture.options, agent: "pi" as const, model, piThinkingLevel: "low" as const, permissionMode: "approve-all" as const, providerPolicy: { readOnly: true }, systemInstructions: "Bound instructions" }; const host = await AcpxRuntimeHost.open(options, fixture.dependencies({ verifyInstallation: async () => ({ commandDigest: profile.commandDigest, @@ -882,12 +884,29 @@ describe("ACPX runtime host", () => { expect(openRuntime).toHaveBeenCalledOnce(); }); + it.each([undefined, "high"] as const)("retires Pi admission when the runtime reports thinking level %s", async piThinkingLevel => { + const fixture = await hostFixture(); + const model = "custom-provider/caller-selected-model"; + const runtime = runtimePort({ + getStatus: async () => ({ models: { currentModelId: model } }), + identity: async () => ({ acpxRecordId: "record-1", backendSessionId: "backend-1", agentSessionId: "agent-1", + ...(piThinkingLevel === undefined ? {} : { piThinkingLevel }) }), + }); + await expect(AcpxRuntimeHost.open({ + ...fixture.options, agent: "pi", model, piThinkingLevel: "low", permissionMode: "deny-all", + providerPolicy: { readOnly: false }, + }, fixture.dependencies({ openRuntime: async () => runtime }))).rejects.toThrow(/thinking level/); + expect(runtime.startTurn).not.toHaveBeenCalled(); + expect(runtime.close).toHaveBeenCalledOnce(); + expect(fixture.commandClose).toHaveBeenCalledOnce(); + }); + it.each(["cursor", "copilot", "pi"] as const)("binds %s agent files from each registered run copy, never ambient roots", async (agent) => { const fixture = await hostFixture(); const copies = await mkdtemp(join(tmpdir(), "paperclip-agent-copies-")); temporaryDirectories.push(copies); const model = agent === "pi" ? "openrouter/deepseek/deepseek-v4-flash-0731" : "explicit-test-model"; - const options = { ...fixture.options, agent, model, permissionMode: "approve-all" as const, + const options = { ...fixture.options, agent, model, ...(agent === "pi" ? { piThinkingLevel: "low" as const } : {}), permissionMode: "approve-all" as const, providerPolicy: { readOnly: false }, environment: { AGENT_HOME: "/ambient/other-agent", PAPERCLIP_PI_AGENT_HOME: "/ambient/other-agent", ...(agent === "pi" ? { OPENROUTER_API_KEY: "test" } : agent === "cursor" ? { CURSOR_API_KEY: "test" } : { COPILOT_GITHUB_TOKEN: "test" }), }, @@ -895,7 +914,7 @@ describe("ACPX runtime host", () => { const opened: AcpxRuntimePortOpenOptions[] = []; const dependencies = fixture.dependencies({ openRuntime: async launch => { opened.push(launch); - return runtimePort({ getStatus: async () => ({ models: { currentModelId: model } }), identity: async () => ({ acpxRecordId: "record-1", backendSessionId: "backend-1", agentSessionId: "agent-1", ...(agent === "cursor" ? { cursorMode: launch.cursorMode } : {}) }) }); + return runtimePort({ getStatus: async () => ({ models: { currentModelId: model } }), identity: async () => ({ acpxRecordId: "record-1", backendSessionId: "backend-1", agentSessionId: "agent-1", ...(agent === "cursor" ? { mode: launch.mode } : {}), ...(agent === "pi" ? { piThinkingLevel: launch.piThinkingLevel } : {}) }) }); } }); // Missing trusted context must not turn ambient values into authority. const withoutCopy = await AcpxRuntimeHost.open(options, dependencies); @@ -919,6 +938,74 @@ describe("ACPX runtime host", () => { } }); + it("reopens Pi with the current registered home in a resumed child shell after prior copies are removed", async () => { + const fixture = await hostFixture(); + const root = await realpath(fixture.root); + const copies = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-home-probe-"))); + temporaryDirectories.push(copies); + const sessionHome = join(root, "pi-session-home"); + await mkdir(join(sessionHome, "sessions"), { recursive: true }); + const sessionPath = join(sessionHome, "sessions", "retained.jsonl"); + await writeFile(sessionPath, "retained provider session"); + const entrypoint = join(root, "inspect-launch.cjs"); + const extension = join(root, "extension.js"); + await writeFile(extension, ""); + // The child replaces only the provider/model boundary. Its shell inherits + // the actual production launch spec, including the registered AGENT_HOME. + await writeFile(entrypoint, ` + const { execFileSync } = require("node:child_process"); + const shell = execFileSync("/bin/bash", ["--noprofile", "--norc", "-c", + 'printf "%s\\n" "$AGENT_HOME"; cat "$AGENT_HOME/notes/warm-memory.txt"'], + { encoding: "utf8", env: process.env }); + console.log(JSON.stringify({ shell, + configuration: JSON.parse(process.env.PAPERCLIP_PI_RUNTIME_CONFIGURATION), + arguments: process.argv.slice(2) })); + `); + const model = "openrouter/deepseek/deepseek-v4-flash-0731"; + const observed: Array<{ shell: string; configuration: { agentHome: string; instructions: string }; arguments: string[] }> = []; + const dependencies = fixture.dependencies({ openRuntime: async launch => { + const spec = createPiLaunchSpec({ cwd: fixture.options.workingDirectory, sessionPath }, { + ...launch.launchEnvironment, + PAPERCLIP_PI_NODE_EXECUTABLE: process.execPath, + PAPERCLIP_PI_ENTRYPOINT: entrypoint, + PAPERCLIP_PI_EXTENSION_PATH: extension, + PI_CODING_AGENT_DIR: sessionHome, + }); + observed.push(JSON.parse(execFileSync(spec.command, spec.args, { + cwd: fixture.options.workingDirectory, env: spec.env, encoding: "utf8", timeout: 5_000, + }))); + return runtimePort({ + getStatus: async () => ({ models: { currentModelId: model } }), + identity: async () => ({ acpxRecordId: "same-record", backendSessionId: "same-session", agentSessionId: "same-session", piThinkingLevel: "low" }), + }); + } }); + let priorHome = "/unregistered-ambient-home"; + for (const turn of [1, 2, 3]) { + const agentHome = join(copies, `turn-${turn}`); + await mkdir(join(agentHome, "notes"), { recursive: true }); + await writeFile(join(agentHome, "notes", "warm-memory.txt"), `T${turn}\n`); + const instructions = `For this turn, AGENT_HOME is ${agentHome}.`; + const runtimeContext = { instructions: { workingCopy: { kind: "agent_files", rootPath: agentHome, entryPath: "AGENTS.md" } }, skills: [], mcp: { bindingId: null } } as unknown as NativeRuntimeContextSnapshot; + const host = await AcpxRuntimeHost.open({ + ...fixture.options, agent: "pi", model, piThinkingLevel: "low", permissionMode: "approve-all", + providerPolicy: { readOnly: false }, runtimeContext, systemInstructions: instructions, + environment: { OPENROUTER_API_KEY: "test-only", AGENT_HOME: priorHome, PAPERCLIP_PI_AGENT_HOME: priorHome }, + }, dependencies); + try { + expect(observed.at(-1)).toMatchObject({ + shell: `${agentHome}\nT${turn}\n`, configuration: { agentHome, instructions }, + arguments: expect.arrayContaining(["--session", sessionPath]), + }); + } finally { + await host.close({ reason: "credential-free home probe complete" }); + } + await rm(agentHome, { recursive: true }); + priorHome = agentHome; + } + expect(observed).toHaveLength(3); + expect(await readFile(sessionPath, "utf8")).toBe("retained provider session"); + }); + it("selects and verifies Claude's qualified reported model", async () => { const fixture = await hostFixture(); let selected = false; diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index af5491064d..8be42da690 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -1,5 +1,7 @@ +import { piProviderConfiguration } from "./pi-provider-config.js"; import { withAcpxTurnCancellation } from "./turn-cancellation.js"; import { resolveAcpxProviderMode } from "./provider-mode.js"; +import { resolvePiThinkingLevel, type PiThinkingLevel } from "./pi-thinking.js"; import { dirname, join } from "node:path"; import { bindAcpxAgentFiles } from "./agent-files-binding.js"; import { assertAcpxProfileEnvironment, assertAcpxProfileWorkspace, classifyAcpxProfileError, verifyAcpxProfileInstallation } from "./profile-installation.js"; @@ -80,6 +82,7 @@ const ACPX_ADMISSION_CLEANUP_RESCHEDULE_MS = 1_000; export interface AcpxRuntimePortIdentity { mode?: string; + piThinkingLevel?: PiThinkingLevel; acpxRecordId: string; backendSessionId: string; agentSessionId: string; @@ -146,6 +149,8 @@ export interface AcpxRuntimePort { } export interface AcpxRuntimePortOpenOptions { + /** A durable identity was admitted; restored native mode must already match. */ + restoringSession?: boolean; /** Ephemeral provider extension metadata; mandatory ACP caps remain host-owned. */ clientCapabilities?: Record; command: VerifiedAcpxCommandLease; @@ -157,6 +162,7 @@ export interface AcpxRuntimePortOpenOptions { providerSessionKey: string; permissionMode: NativeAcpxPermissionMode; mode?: string; + piThinkingLevel?: PiThinkingLevel; permissionPolicy: ReturnType; launchEnvironment: Readonly; /** Kernel credential-home quorum inherited by the provider sentinel. */ @@ -231,6 +237,7 @@ export interface OpenAcpxRuntimeHostOptions { model: string; permissionMode: NativeAcpxPermissionMode; mode?: string; + piThinkingLevel?: PiThinkingLevel; systemInstructions?: string; runtimeContext?: NativeRuntimeContextSnapshot | null; environment?: NodeJS.ProcessEnv; @@ -339,8 +346,10 @@ export class AcpxRuntimeHost { workingDirectory: options.workingDirectory, profile, requestedModel: options.model, + ...(options.agent === "pi" ? { providerConfigurationDigest: piProviderConfiguration(options.environment)?.digest } : {}), permissionMode: options.permissionMode, mode: resolveAcpxProviderMode(options.agent, options.mode), + piThinkingLevel: resolvePiThinkingLevel(options.agent, options.piThinkingLevel), ...(["cursor", "copilot", "pi"].includes(options.agent) && options.providerPolicy !== undefined ? { providerPolicy: options.providerPolicy } : {}), }), @@ -474,14 +483,23 @@ export class AcpxRuntimeHost { // 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" + const agentFiles = ["cursor", "copilot", "pi"].includes(options.agent) ? bindAcpxAgentFiles(options.runtimeContext, [sandbox.root, ...(installation.agentServerPackageJsonPath === null ? [] : [dirname(installation.agentServerPackageJsonPath)]), ]) : null; if (agentFiles) { launchEnvironment = Object.freeze({ ...launchEnvironment, AGENT_HOME: agentFiles.root, + ...(options.agent === "pi" ? { PAPERCLIP_PI_AGENT_HOME: agentFiles.root } : {}), }); } + if (options.agent === "copilot") { + // A rejected contender must never overwrite an active provider's text. + // Do not race this write against cancellation: retain the lifetime lease + // until the atomic refresh settles, then honor an intervening abort. + options.signal?.throwIfAborted(); + await refreshCopilotSystemInstructions(sandbox, boundedInstructions(options.systemInstructions)); + options.signal?.throwIfAborted(); + } if (options.agent === "pi") { const skills = await acquireAbortableAdmissionResource({ signal: options.signal, @@ -518,7 +536,7 @@ export class AcpxRuntimeHost { } command = await acquireAbortableAdmissionResource({ signal: options.signal, - acquire: () => installation.openCommand(), + acquire: () => installation.openCommand({ signal: options.signal }), resource: "command", releaseLate: (lateCommand) => lateCommand.close(), reportFailure: (failure) => @@ -526,7 +544,7 @@ export class AcpxRuntimeHost { }); const commandOwner = createAcpxCommandLeaseOwner( command, - () => installation.openCommand(), + signal => installation.openCommand({ signal }), ); command = commandOwner.command; toolBridge = options.semanticTools @@ -559,8 +577,10 @@ export class AcpxRuntimeHost { cwd: binding.workspacePath, stateDirectory: sandbox.stateDirectory, providerSessionKey: binding.profileSessionKey, + restoringSession: options.expectedIdentity !== undefined, permissionMode: binding.permissionMode, mode: binding.mode, + piThinkingLevel: binding.piThinkingLevel, permissionPolicy: acpxRuntimePermissionPolicy( binding.permissionMode, ), @@ -621,6 +641,9 @@ export class AcpxRuntimeHost { if (runtimeIdentity.mode !== binding.mode) { throw new Error("ACPX runtime Provider mode does not match the admitted session configuration"); } + if (runtimeIdentity.piThinkingLevel !== binding.piThinkingLevel) { + throw new Error("ACPX runtime Pi thinking level conflicts with session binding"); + } const observedIdentity: AcpxExpectedSessionIdentity = { kind: "acpx", normalizedSessionId: binding.normalizedSessionId, diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts index fc6961a1ae..ab14aa666f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -475,6 +475,7 @@ async function sandboxFixture(agent: "pi" | "claude" | "codex" | "grok" | "curso workingDirectory: workspace, profile: resolveQualifiedAcpxProfile(agent, models[agent]), requestedModel: models[agent], + ...(agent === "pi" ? { piThinkingLevel: "low" as const } : {}), permissionMode: "approve-reads", }); return { root, binding }; diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index ee0ca2beed..3e0e52d250 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -1,3 +1,4 @@ +import { piProviderConfiguration } from "./pi-provider-config.js"; import { configuredEnvironmentKeys } from "../../configured-environment.js"; import { COPILOT_SYSTEM_INSTRUCTIONS_FILE } from "./copilot-profile.js"; import { randomBytes } from "node:crypto"; @@ -389,6 +390,10 @@ export async function prepareAcpxRuntimeSandbox(input: { workspaceRecordPath, `${input.binding.workspacePath}\n`, ); + if (input.agent === "pi") { + const configuration = piProviderConfiguration(input.environment); + if (configuration) await writePrivateFile(join(agentHomeDirectory, "models.json"), configuration.json); + } if (input.agent === "claude") { // ACP otherwise rewrites exact IDs (including user-entered model IDs) to // picker aliases such as "sonnet". Its supported availableModels setting diff --git a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts index 6e457d35e2..d9ce539a9f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts +++ b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts @@ -46,6 +46,7 @@ export interface AcpxSidecarOpenParams { model: string; permissionMode: NativeAcpxPermissionMode; mode?: string; + piThinkingLevel?: "off" | "low" | "high" | "max"; permissionModePinned: boolean; providerPolicy?: { readOnly: boolean }; systemInstructions: string; @@ -67,6 +68,7 @@ export interface AcpxExpectedSessionIdentity { effectiveModel: string; permissionMode?: NativeAcpxPermissionMode; mode?: string; + piThinkingLevel?: "off" | "low" | "high" | "max"; 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 index b0c597ce56..8bd93e1c3c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/single-read-evidence.test.ts @@ -2,6 +2,7 @@ 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 { createCopilotToolEvidence } from "./copilot-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")}`; @@ -43,7 +44,7 @@ it("completes an empty pending origin only from its full shape before execution expect(updateSingleReadEvidence(unproven, { tag: "tool_call_update", rawInput: { path } }, "/workspace")).toEqual({}); } }); -for (const [provider, create] of [["cursor", createCursorToolEvidence]] as const) { +for (const [provider, create] of [["cursor", createCursorToolEvidence], ["copilot", createCopilotToolEvidence]] 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 }; } diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts index 5e6fac128d..3e08de1f22 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts @@ -44,6 +44,8 @@ export interface CodexAppServerTransport { notify(method: string, params?: Record): void; notifications(): AsyncIterable; setServerRequestHandler(handler: CodexServerRequestHandler): void; + /** One-shot requests authenticated by an adopted live provider, never a saved checkpoint alone. */ + takeRestoredRuntimeRequests?(): CodexRpcServerRequest[]; /** * Optional provider-neutral resolution path used when a transport has * already normalized a native server request behind another PRP boundary. diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts index ecc4c6167d..cd9ee34129 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts @@ -42,8 +42,30 @@ import { type PrpEvent, type PrpStructuredRunResult, } from "./codex-app-server-driver.test-support.js"; +import { rehydrateRunnerdItemNotification } from "../../live/runnerd-codex-transport.js"; describe("Codex app-server Codex driver", () => { + it("retains one correlation-bound steering acknowledgement after the rehydrated runnerd echo", async () => { + const transport = new FakeCodexTransport("thread-root"); + const session = await makeDriver([transport]).openSession({ + runId: "run-steering-echo", normalizedSessionId: "normalized-steering-echo", workingDirectory: WORKSPACE, + }); + const { turnId } = await session.startTurn({ message: { role: "user", text: "Start." } }); + await session.steer?.({ turnId, message: { role: "user", text: "Stay concise." }, correlationId: "queued-comment-1" }); + transport.push("item/completed", rehydrateRunnerdItemNotification({ + provider: "acpx", providerTurnId: turnId, itemId: `acpx-control-${"a".repeat(64)}`, + kind: "steering_acknowledgement", mode: "steer", status: "acknowledged", + text: "Steering acknowledged for the active turn.", + }, "thread-root", turnId)); + await new Promise(resolve => setTimeout(resolve, 0)); + await session.close({ reason: "fixture complete" }); + const events: PrpEvent[] = []; + for await (const event of session.events()) events.push(event); + expect(events.filter(event => event.eventType === "item.completed" && event.payload.kind === "steering_acknowledgement")) + .toEqual([expect.objectContaining({ turnId, itemId: `${turnId}:steer:queued-comment-1`, + payload: expect.objectContaining({ kind: "steering_acknowledgement", status: "acknowledged" }) })]); + }); + it("captures an exact skillless model/environment snapshot with credentials absent", async () => { const transport = new FakeCodexTransport(); const session = await makeDriver([transport]).openSession({ diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.recovery.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.recovery.test.ts index ea972d1b7c..329e2fc00e 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.recovery.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.recovery.test.ts @@ -44,7 +44,7 @@ import { } from "./codex-app-server-driver.test-support.js"; import { NativeSessionProtocolIntegrityError } from "../../contracts/native-session-backend.js"; -function cursorProviderIdentity(cursorMode: unknown) { +function cursorProviderIdentity(mode: unknown) { return { kind: "acpx", normalizedSessionId: "normalized-cursor-recovery", @@ -56,33 +56,53 @@ function cursorProviderIdentity(cursorMode: unknown) { requestedModel: "explicit-cursor-model", effectiveModel: "explicit-cursor-model", permissionMode: "approve-all", - ...(cursorMode === undefined ? {} : { cursorMode }), + ...(mode === undefined ? {} : { mode }), providerLifetimeFenceCandidates: [60_001, 60_002, 60_003], }; } describe("Codex app-server Codex driver", () => { - it("does not resend a restart continuation when its history anchor is missing", async () => { - const first = new FakeCodexTransport(); - const second = new FakeCodexTransport(); + it.each(["agent", "plan", "ask"])("retains native Cursor %s mode through checkpoint and recovery", async (mode) => { + const identity = cursorProviderIdentity(mode); + const first = new FakeCodexTransport("thread-1", "provider-session-1", identity); + const second = new FakeCodexTransport("thread-1", "provider-session-1", identity); const driver = makeDriver([first, second]); const original = await driver.openSession({ - runId: "run-restart-anchor", normalizedSessionId: "session-restart-anchor", workingDirectory: WORKSPACE, + runId: "run-cursor-recovery", normalizedSessionId: "normalized-cursor-recovery", workingDirectory: WORKSPACE, }); - await original.startTurn({ message: { role: "user", text: "Finish the request." } }); - first.push("turn/completed", { threadId: "thread-1", turn: { - id: "turn-1", status: "failed", error: { code: "provider_turn_lost_on_restore", recoverable: true }, items: [], - } }); - await collectUntilTerminal(original.events()); const snapshot = await original.snapshot(); - await original.close({ reason: "controller lost before continuation checkpoint" }); - second.readResponse = { thread: { id: "thread-1", sessionId: "provider-session-1", cwd: WORKSPACE, - turns: [{ id: "uncheckpointed-turn", status: "completed", items: [] }], - } }; - await expect(driver.recoverSession?.(snapshot)).resolves.toMatchObject({ - recovered: false, reason: "restart interruption history is incomplete", + expect(snapshot.providerIdentity).toEqual(identity); + await original.close({ reason: "controller disconnected" }); + + const recovery = await driver.recoverSession(snapshot); + expect(recovery.recovered).toBe(true); + expect((await recovery.session!.snapshot()).providerIdentity).toEqual(identity); + await recovery.session!.close({ reason: "test complete" }); + }); + + it.each([undefined, "agent", "ask"])("refuses recovery when native Cursor plan mode becomes %s", async (changedMode) => { + const first = new FakeCodexTransport("thread-1", "provider-session-1", cursorProviderIdentity("plan")); + const second = new FakeCodexTransport("thread-1", "provider-session-1", cursorProviderIdentity(changedMode)); + const driver = makeDriver([first, second]); + const original = await driver.openSession({ + runId: "run-cursor-recovery", normalizedSessionId: "normalized-cursor-recovery", workingDirectory: WORKSPACE, }); - expect(second.calls.some(call => call.method === "turn/start")).toBe(false); + const snapshot = await original.snapshot(); + await original.close({ reason: "controller disconnected" }); + + await expect(driver.recoverSession(snapshot)).resolves.toEqual({ + recovered: false, reason: "provider resumed with a different tagged session identity", + }); + expect(second.calls.some((call) => call.method === "turn/start")).toBe(false); + }); + + it.each([null, "", 3])("rejects malformed native provider mode %j before opening a session", async (mode) => { + const transport = new FakeCodexTransport("thread-1", "provider-session-1", cursorProviderIdentity(mode)); + const driver = makeDriver([transport]); + await expect(driver.openSession({ + runId: "run-cursor-recovery", normalizedSessionId: "normalized-cursor-recovery", workingDirectory: WORKSPACE, + })).rejects.toThrow("ACPX provider identity contains an invalid provider mode"); + expect(transport.calls.some((call) => call.method === "turn/start")).toBe(false); }); it.each([null, "checkpointed-prior-turn"])("recovers an autonomous goal turn beyond checkpoint %s", async (checkpointTurnId) => { diff --git a/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts b/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts index fe52e695aa..c6644732cb 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts @@ -1,3 +1,4 @@ +import { resolvePiThinkingLevel } from "../acpx/pi-thinking.js"; import { isProviderMode } from "../../contracts/provider-mode.js"; import type { PersistedHarnessProviderIdentity } from "../../contracts/harness-driver.js"; import type { NativeUserMessage } from "../../contracts/types.js"; @@ -79,6 +80,7 @@ export function parseProviderIdentity( "ACPX provider identity contains an invalid permission mode", ); } + const piThinkingLevel = identity.piThinkingLevel === undefined ? undefined : resolvePiThinkingLevel("pi", identity.piThinkingLevel); const mode = identity.mode; if (mode !== undefined && !isProviderMode(mode)) { throw new Error("ACPX provider identity contains an invalid provider mode"); @@ -109,6 +111,7 @@ export function parseProviderIdentity( effectiveModel: identity.effectiveModel as string, ...(permissionMode === undefined ? {} : { permissionMode }), ...(mode === undefined ? {} : { mode }), + ...(piThinkingLevel === undefined ? {} : { piThinkingLevel }), providerLifetimeFenceCandidates: fenceCandidates as [ number, number, diff --git a/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts b/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts index 2b22e9bbb2..4efc9eec23 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts @@ -64,7 +64,17 @@ export class CodexHarnessSession this.transport.setServerRequestHandler((request) => handleServerRequest(this, request), ); - initializeCodexSessionEvents(this, input); + const restored = this.transport.takeRestoredRuntimeRequests?.() ?? []; + const restoredIds = new Set(restored.map(request => String(request.id))); + initializeCodexSessionEvents(this, { + ...input, + stalePendingRuntimeRequests: input.stalePendingRuntimeRequests?.filter(request => !restoredIds.has(request.requestId)), + }); + for (const request of restored) { + void handleServerRequest(this, request, { restored: true }).catch(error => this.failProtocol( + "runtime_request_recovery_failed", error instanceof Error ? error.message : String(error), + )); + } if (this.terminal) { this.eventQueue.close(); } else { diff --git a/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts b/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts index 525feb8dce..ba2581bbcf 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts @@ -331,12 +331,12 @@ async function mapNotificationBody(state: CodexSessionState, notification: Codex if ( notification.method === "item/completed" && text(params.kind) === "steering_acknowledgement" - && Object.keys(item).length === 0 ) { // runnerd persists its own command acknowledgement as a canonical PRP // item. The request() call is already the authoritative acknowledgement // and steer() emits the user-visible item with the active turn binding. - // Do not reinterpret this transport-level echo as an unbound Codex item. + // Rehydration adds an item object to this transport echo; it still must + // not become a second acknowledgement beside the correlation-bound item. return; } if (notification.method === "paperclip/runResult") { diff --git a/packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts b/packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts index 73b4bd6ddd..0eb5ccf257 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts @@ -30,11 +30,12 @@ import { boundedText, dynamicToolResponse, record, text } from "./codex-driver-v export async function handleServerRequest( state: CodexSessionState, request: CodexRpcServerRequest, + options: { restored?: true } = {}, ): Promise> { const sourceSequenceBefore = state.sourceSequence; let rejected = false; try { - const response = await handleServerRequestBody(state, request); + const response = await handleServerRequestBody(state, request, options.restored === true); rejected = response.success === false; return response; } catch (error) { @@ -80,6 +81,7 @@ export async function handleServerRequest( async function handleServerRequestBody( state: CodexSessionState, request: CodexRpcServerRequest, + restored: boolean, ): Promise> { if (request.method === "item/tool/call") { // Provider requests and turn/start responses have independent delivery @@ -308,7 +310,9 @@ async function handleServerRequestBody( method: request.method, }, }; - state.emit( + // Recovery rebinds an already durable callback. Its creation event is + // retained in the original run; emitting it again duplicates authority. + if (!restored) state.emit( "runtime_request.created", { request: runtimeRequestProtocolPayload(runtimeRequest), diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index 2641cf334c..29bf5d6fbe 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -72,6 +72,7 @@ export { type RunnerdCodexTransportOptions, } from "./live/runnerd-codex-transport.js"; export * from "./live/workspace-file-reference.js"; +export { readLinuxProcessStartedAt, type LinuxProcessStartOptions } from "./live/linux-process-start.js"; export * from "./protocol/replay-contract.js"; export * from "./protocol/replay-loader.js"; export * from "./protocol/result-normalization.js"; @@ -84,5 +85,7 @@ export * from "./semantic-tools/index.js"; export * as acceptedCapabilitySemanticTools from "./semantic-tools/index.js"; export * from "./compatibility.js"; -export { RunnerdDotDriver, type RunnerdDotDriverOptions } from "./drivers/dot/runnerd-dot-driver.js"; +export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxPiInstallation } from "./drivers/acpx/installation-integrity.js"; export { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "./live/bundled-remote-provider-pack.js"; + +export { RunnerdDotDriver, type RunnerdDotDriverOptions } from "./drivers/dot/runnerd-dot-driver.js"; diff --git a/packages/paperclip-runner/src/live/acpx-request-recovery.test.ts b/packages/paperclip-runner/src/live/acpx-request-recovery.test.ts new file mode 100644 index 0000000000..4eb952ed63 --- /dev/null +++ b/packages/paperclip-runner/src/live/acpx-request-recovery.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from "vitest"; +import { liveAcpxRuntimeRequests } from "./runnerd-codex-transport.js"; +import { FakeCodexTransport, makeDriver, WORKSPACE } from "../drivers/codex/codex-app-server-driver.test-support.js"; +import type { PrpEvent } from "../protocol/replay-contract.js"; +import type { CodexRpcServerRequest } from "../drivers/codex/app-server-transport.js"; + +function snapshot() { + return { + provider: "acpx", runtimeRequestsLive: true, + driverSessionId: "thread-1", activeProviderTurnId: "turn-1", runtimeRequestTurnId: "durable-turn-1", + pendingRuntimeRequests: [{ + schema: "paperclip.runtime_request.v2", requestId: "input-1", type: "input", + requestKind: "runtime", status: "pending", turnId: "durable-turn-1", itemId: "question-1", + origin: { adapter: "acpx-runtime-sidecar", provider: "pi", method: "elicitation/create" }, + input: { schema: "paperclip.question_set.v1", questions: [ + { id: "answer", prompt: "Give the answer", required: true, answerMode: "text" }, + ] }, + }, { + schema: "paperclip.runtime_request.v2", requestId: "permission-1", type: "permission", + requestKind: "permission_approval", status: "pending", turnId: "durable-turn-1", itemId: "write-1", + prompt: "Allow this write?", choices: [{ key: "deny", label: "Decline", outcome: "cancel" }], + details: { toolCallId: "write-1" }, + origin: { adapter: "acpx-runtime-sidecar", provider: "pi", method: "session/request_permission" }, + }], + }; +} + +describe("live ACP request recovery", () => { + it("retains original question, option and permission identities", () => { + const value = snapshot(); + const requests = liveAcpxRuntimeRequests(value, "thread-1", "turn-1", "durable-turn-1"); + expect(requests.map(request => [request.id, request.method])).toEqual([ + ["input-1", "elicitation/create"], ["permission-1", "session/request_permission"], + ]); + expect(requests.every(request => request.params.turnId === "turn-1")).toBe(true); + expect(requests[0]!.params.questionSet).toEqual(value.pendingRuntimeRequests[0]!.input); + expect(requests[1]!.params).toMatchObject({ choices: value.pendingRuntimeRequests[1]!.choices, toolCallId: "write-1" }); + }); + + it.each([ + ["missing live attestation", { runtimeRequestsLive: false }], + ["wrong provider", { provider: "codex" }], + ["wrong session", { driverSessionId: "other" }], + ["wrong provider turn", { activeProviderTurnId: "other" }], + ["wrong durable turn", { runtimeRequestTurnId: "other" }], + ["missing ledger", { pendingRuntimeRequests: null }], + ])("rejects %s", (_name, change) => { + expect(() => liveAcpxRuntimeRequests({ ...snapshot(), ...change }, "thread-1", "turn-1", "durable-turn-1")).toThrow(); + }); + + it.each([ + { requestId: "" }, { status: "resolved" }, { turnId: "old-turn" }, + { schema: "paperclip.runtime_request.v1" }, { type: "permission" }, + { origin: { method: "item/tool/call" } }, { input: null }, + ])("rejects malformed or stale callback %j", change => { + const value = snapshot(); + expect(() => liveAcpxRuntimeRequests({ ...value, pendingRuntimeRequests: [{ ...value.pendingRuntimeRequests[0], ...change }] }, "thread-1", "turn-1", "durable-turn-1")).toThrow(); + }); + + it("rejects duplicate or oversized callback ledgers", () => { + const value = snapshot(); + for (const count of [2, 1025]) { + expect(() => liveAcpxRuntimeRequests({ ...value, pendingRuntimeRequests: Array(count).fill(value.pendingRuntimeRequests[0]) }, "thread-1", "turn-1", "durable-turn-1")).toThrow(); + } + }); + + it.each(["input-1", "permission-1"])("restores and delivers %s once after controller recovery", async requestId => { + const requests = liveAcpxRuntimeRequests(snapshot(), "thread-1", "turn-1", "durable-turn-1"); + const originalTransport = new FakeCodexTransport(); + const original = await makeDriver([originalTransport]).openSession({ runId: "run-1", normalizedSessionId: "session-1", workingDirectory: WORKSPACE }); + let recovered: Awaited> | undefined; + try { + await original.startTurn({ message: { role: "user", text: "Wait for input" } }); + for (const request of requests) void originalTransport.invoke(request); + await Promise.resolve(); + recovered = await original.snapshot(); + expect(recovered.pendingRuntimeRequests).toHaveLength(2); + } finally { + await original.close(); + } + class AdoptedTransport extends FakeCodexTransport { + takeRestoredRuntimeRequests(): CodexRpcServerRequest[] { return requests; } + } + const transport = new AdoptedTransport(); + const deliver = vi.fn(async () => {}); + transport.runtimeRequestResolver = deliver; + const recovery = await makeDriver([transport]).recoverSession(recovered!); + expect(recovery.recovered).toBe(true); + const session = recovery.session!; + const recoveredEvents: PrpEvent[] = []; + const drained = (async () => { for await (const event of session.events()) recoveredEvents.push(event); })(); + try { + expect(session.pendingRuntimeRequests?.().map(request => request.requestId)).toEqual(["input-1", "permission-1"]); + const resolution = requestId === "input-1" + ? { action: "submit" as const, content: { schema: "paperclip.question_response.v1", answers: { answer: { text: "undisclosed answer" } } } } + : { action: "decline" as const }; + await session.resolveRuntimeRequest?.({ requestId, turnId: "turn-1", resolution }); + expect(deliver).toHaveBeenCalledExactlyOnceWith({ requestId, turnId: "turn-1", resolution }); + await expect(session.resolveRuntimeRequest?.({ requestId, turnId: "turn-1", resolution })).rejects.toThrow("no longer pending"); + expect(deliver).toHaveBeenCalledTimes(1); + } finally { + await session.close(); await drained; + expect(recoveredEvents.map(event => event.eventType)).not.toContain("runtime_request.created"); + expect(recoveredEvents.filter(event => event.eventType === "runtime_request.expired").map(event => event.payload.requestId)).not.toContain(requestId); + } + }); +}); diff --git a/packages/paperclip-runner/src/live/fixtures/fake-pi-warm-sidecar.cjs b/packages/paperclip-runner/src/live/fixtures/fake-pi-warm-sidecar.cjs new file mode 100644 index 0000000000..9cf8cef901 --- /dev/null +++ b/packages/paperclip-runner/src/live/fixtures/fake-pi-warm-sidecar.cjs @@ -0,0 +1,80 @@ +// Protocol-only fixture: no credentials, network, or model calls. +const { appendFileSync } = require("node:fs"); +const { createInterface } = require("node:readline"); +// The test binds configuration into the verified script snapshot before launch. +const config = /* fixture-config */ null; +let identity; +let runId; +let turnId; +let sequence = 1; +const send = (value) => process.stdout.write(`${JSON.stringify({ protocolVersion: 2, ...value })}\n`); +const event = (eventType, payload) => send({ sequence: sequence++, eventType, runId, turnId, payload }); +const journal = (value) => appendFileSync(config.journal, `${JSON.stringify({ pid: process.pid, ...value })}\n`); +journal({ event: "spawn" }); + +async function handle({ id, command, params = {} }) { + journal({ command, resumed: command === "session.open" && !!params.expectedIdentity }); + let result; + switch (command) { + case "initialize": + result = { protocolVersion: 2, sidecarPid: process.pid, profile: { agent: "pi" }, capabilities: { + persistentSessions: true, exactModelVerification: true, permissions: "runner_policy", + semanticTools: "runner_bridge", structuredInput: "paperclip.question_set.v1", + } }; + break; + case "session.open": + if (params.expectedIdentity) await new Promise((resolve) => setTimeout(resolve, config.resumeDelayMs)); + identity = params.expectedIdentity ?? { + kind: "acpx", normalizedSessionId: params.normalizedSessionId, + acpxRecordId: "record-warm", backendSessionId: "backend-warm", agentSessionId: "agent-warm", + profileDigest: config.commandDigest, + workspaceDigest: `sha256:${"2".repeat(64)}`, requestedModel: params.model, effectiveModel: params.model, + permissionMode: params.permissionMode, piThinkingLevel: params.piThinkingLevel, + providerLifetimeFenceCandidates: [61001, 61002, 61003], + }; + result = { sidecarPid: process.pid, identity, status: {}, turnControls: { steering: true, queuedFollowUp: true } }; + break; + case "run.attach": + runId = params.runId; + result = { runId, catalogRevision: params.catalogRevision }; + break; + case "turn.start": + turnId = params.turnId; + result = { turnId, turnControls: { steering: true, queuedFollowUp: true } }; + break; + case "tool.resolve": + if (params.error) throw new Error(`Completion rejected: ${JSON.stringify(params.error)}`); + result = { resolved: true }; + break; + case "session.snapshot": + result = { identity, runId, turnId: null, pendingRuntimeRequests: [] }; + break; + case "session.goal.get": + result = { schema: "paperclip.session_goal.snapshot.v1", goal: null, workingNow: false, sessionGoals: { + availability: "available", actions: ["set", "pause", "resume", "clear"], autonomousUpdates: true, + persistentAcrossResume: true, maxObjectiveChars: 4000, tokenBudgetControl: false, usageReporting: false, + } }; + break; + case "session.suspend": result = { suspended: true, identity }; break; + case "session.close": result = { closed: true }; break; + case "turn.cancel": result = { cancelled: true }; break; + default: throw new Error(`Unexpected fixture command ${command}`); + } + send({ id, ok: true, result }); + if (command === "turn.start") event("runtime.tool_called", { + callId: `finish-${turnId}`, operationId: "paperclip_finish", input: { + schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: "Fixture turn completed.", + completionClaim: { contractRevision: "warm-pi-contract", objectiveSatisfied: true, criteria: [], remainingWork: [] }, + evidence: [], verification: [], attentionRequests: [], artifacts: [], + }, + }); + if (command === "tool.resolve") event("runtime.turn_terminal", { status: "completed" }); +} + +let handling = Promise.resolve(); +createInterface({ input: process.stdin }).on("line", (line) => { + handling = handling.then(() => handle(JSON.parse(line))).catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exit(1); + }); +}); diff --git a/packages/paperclip-runner/src/live/index.ts b/packages/paperclip-runner/src/live/index.ts index 15737c6481..cd3f85e2f4 100644 --- a/packages/paperclip-runner/src/live/index.ts +++ b/packages/paperclip-runner/src/live/index.ts @@ -4,8 +4,9 @@ export * from "./live-session.js"; export * from "./durable-live-session-store.js"; export * from "./runnerd-codex-transport.js"; export * from "./turn-stream.js"; +export * from "./linux-process-start.js"; -export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation } from "../drivers/acpx/installation-integrity.js"; +export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxPiInstallation } from "../drivers/acpx/installation-integrity.js"; export { probeAcpxCursorInstallation } from "../drivers/acpx/profile-installation.js"; diff --git a/packages/paperclip-runner/src/live/linux-process-start.test.ts b/packages/paperclip-runner/src/live/linux-process-start.test.ts new file mode 100644 index 0000000000..4cc8a68444 --- /dev/null +++ b/packages/paperclip-runner/src/live/linux-process-start.test.ts @@ -0,0 +1,41 @@ +import { spawn } from "node:child_process"; +import { expect, it } from "vitest"; +import { readLinuxProcessStartedAt } from "./linux-process-start.js"; + +const stat = (ticks: string, command = "runner") => `123 (${command}) S ${Array(18).fill("0").join(" ")} ${ticks}\n`; +const options = (processStat = stat("123"), systemStat = "btime 1785546000\n", clockTicksPerSecond = 100) => ({ + clockTicksPerSecond, + readFile: (path: string) => path === "/proc/123/stat" ? processStat : systemStat, +}); + +it("uses kernel birth ticks even when the command contains spaces and parentheses", () => { + expect(readLinuxProcessStartedAt(123, options(stat("123", "a ) b ( c")))) + .toBe(new Date(1785546001230).toISOString()); + expect(readLinuxProcessStartedAt(123, options(stat("124")))) + .not.toBe(readLinuxProcessStartedAt(123, options())); +}); + +it.each([ + [stat("bad"), "btime 1785546000\n", 100], + [stat("123").replace("123 (", "124 ("), "btime 1785546000\n", 100], + ["123 (runner) S", "btime 1785546000\n", 100], + [stat("123"), "btime invalid\n", 100], + [stat("123"), "btime 1785546000\n", 0], + [stat("123"), "btime 1785546000\n", Number.NaN], + [stat("123"), "btime 999999999999999999999\n", 100], +])("fails closed on malformed birth metadata or clock frequency", (processStat, systemStat, frequency) => { + expect(() => readLinuxProcessStartedAt(123, options(processStat, systemStat, frequency))).toThrow(); +}); + +it.skipIf(process.platform !== "linux")("keeps the actual owned child's birth identity stable through a delayed observation", async () => { + const child = spawn("/bin/sleep", ["10"], { stdio: "ignore" }); + const closed = new Promise(resolve => child.once("close", () => resolve())); + try { + expect(child.pid).toBeGreaterThan(0); + const first = readLinuxProcessStartedAt(child.pid!); + await new Promise(resolve => setTimeout(resolve, 1100)); + expect(readLinuxProcessStartedAt(child.pid!)).toBe(first); + child.kill("SIGTERM"); await closed; + expect(() => readLinuxProcessStartedAt(child.pid!)).toThrow(); + } finally { child.kill("SIGTERM"); await closed; } +}, 5000); diff --git a/packages/paperclip-runner/src/live/linux-process-start.ts b/packages/paperclip-runner/src/live/linux-process-start.ts new file mode 100644 index 0000000000..66abe1322d --- /dev/null +++ b/packages/paperclip-runner/src/live/linux-process-start.ts @@ -0,0 +1,40 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; + +let clockTicksPerSecond: number | undefined; + +export interface LinuxProcessStartOptions { + readFile?: (path: string) => string; + clockTicksPerSecond?: number; +} + +/** /proc/PID directory ctime is lookup metadata, not the process's birth time. */ +export function readLinuxProcessStartedAt(pid: number, options: LinuxProcessStartOptions = {}): string { + if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("Invalid Linux process PID"); + const readFile = options.readFile ?? ((path: string) => readFileSync(path, "utf8")); + const processStat = readFile(`/proc/${pid}/stat`); + // comm can contain spaces and parentheses. Fields after its final ')' start + // at field 3 (state), so field 22 (starttime) is index 19 in this suffix. + const end = processStat.lastIndexOf(") "); + const fields = end >= 0 ? processStat.slice(end + 2).trim().split(/\s+/) : []; + const startTicks = fields[19]; + const bootSeconds = /^btime (\d+)$/m.exec(readFile("/proc/stat"))?.[1]; + if (!processStat.startsWith(`${pid} (`) || !/^\d+$/.test(startTicks ?? "") || !bootSeconds) { + throw new Error("Invalid Linux process birth metadata"); + } + let ticks = options.clockTicksPerSecond; + if (ticks === undefined) { + clockTicksPerSecond ??= Number(execFileSync("getconf", ["CLK_TCK"], { + encoding: "utf8", timeout: 1_500, windowsHide: true, + env: { PATH: "/usr/bin:/bin", LANG: "C", LC_ALL: "C" }, + }).trim()); + ticks = clockTicksPerSecond; + } + if (!Number.isSafeInteger(ticks) || ticks <= 0) throw new Error("Invalid Linux clock tick frequency"); + const milliseconds = BigInt(bootSeconds) * 1_000n + BigInt(startTicks!) * 1_000n / BigInt(ticks); + const value = Number(milliseconds); + if (!Number.isSafeInteger(value) || !Number.isFinite(new Date(value).getTime())) { + throw new Error("Invalid Linux process birth timestamp"); + } + return new Date(value).toISOString(); +} diff --git a/packages/paperclip-runner/src/live/live-session.test.ts b/packages/paperclip-runner/src/live/live-session.test.ts index 01ca6e2a9f..ccf7f3d95b 100644 --- a/packages/paperclip-runner/src/live/live-session.test.ts +++ b/packages/paperclip-runner/src/live/live-session.test.ts @@ -1,3 +1,4 @@ +import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js"; import { describe, expect, it, vi } from "vitest"; import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; @@ -29,25 +30,25 @@ import * as workspaceDiff from "./workspace-diff.js"; it.each(["pi", "copilot"] as const)("requires separately bound evaluation opt-in for %s", async (acpxAgent) => { const service = new CapabilityLiveSessionService(); - await expect(service.create({ provider: "acpx", acpxAgent, requestedModel: "explicit-model" })) + await expect(service.create({ provider: "acpx", acpxAgent, ...(acpxAgent === "pi" ? { piThinkingLevel: "low" as const } : {}), requestedModel: "explicit-model" })) .rejects.toThrow("explicit evaluation opt-in"); const mismatched = new CapabilityLiveSessionService({ transportOptions: { - acpxCandidateProfile: "pi", + acpxCandidateProfile: acpxAgent === "pi" ? "copilot" : "pi", } }); - await expect(mismatched.create({ provider: "acpx", acpxAgent, requestedModel: "explicit-model" })) + await expect(mismatched.create({ provider: "acpx", acpxAgent, ...(acpxAgent === "pi" ? { piThinkingLevel: "low" as const } : {}), requestedModel: "explicit-model" })) .rejects.toThrow("explicit evaluation opt-in"); }); -it("admits Pi live sessions without candidate opt-in while preserving the exact profile", async () => { - const service = new CapabilityLiveSessionService({ transportFactory: fakeTransportFactory(providerState()) }); - const session = await service.create({ provider: "acpx", acpxAgent: "pi", requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731" }); +it("admits Pi evaluation sessions only with candidate opt-in while preserving the exact profile", async () => { + const service = new CapabilityLiveSessionService({ transportFactory: fakeTransportFactory(providerState()), transportOptions: { acpxCandidateProfile: "pi" } }); + const session = await service.create({ provider: "acpx", acpxAgent: "pi", piThinkingLevel: "low", requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731" }); try { expect(session.snapshot().config.acpxProfile).toMatchObject({ - agent: "pi", agentProfileVersion: 12, - commandDigest: "sha256:47306e6d2a9b59e8f9189f725ebb7a0a7f91826044d1739e1a35ab31f228ba1f", + agent: "pi", ...resolveQualifiedAcpxProfile("pi", "openrouter/deepseek/deepseek-v4-flash-0731"), }); - await expect(service.create({ provider: "acpx", acpxAgent: "pi", requestedModel: "another-model" })) - .rejects.toThrow("requires exact model"); + const custom = await service.create({ provider: "acpx", acpxAgent: "pi", piThinkingLevel: "low", requestedModel: "another-model" }); + expect(custom.snapshot().config.acpxProfile).toMatchObject({ qualificationModel: "another-model", reportedModelId: "another-model" }); + await custom.shutdown("test complete"); } finally { await session.shutdown("test complete"); } }); @@ -1345,7 +1346,7 @@ describe("Capability live runnerd and Codex session", () => { queue.push({ method: "turn/completed", params: { threadId: state.threadId, turn: { id: turnId, status: "completed" } } }); }; const service = new CapabilityLiveSessionService({ transportFactory: fakeTransportFactory(state), transportOptions: { acpxCandidateProfile: acpxAgent } }); - const session = await service.create({ provider: "acpx", acpxAgent, requestedModel: acpxAgent === "pi" ? "openrouter/deepseek/deepseek-v4-flash-0731" : "exact-model" }); + const session = await service.create({ provider: "acpx", acpxAgent, ...(acpxAgent === "pi" ? { piThinkingLevel: "low" as const } : {}), requestedModel: acpxAgent === "pi" ? "openrouter/deepseek/deepseek-v4-flash-0731" : "exact-model" }); try { const result = await session.sendMessage("Orient to this task."); expect(result.status).toBe("completed"); expect(retained(result.snapshot)).toEqual([]); @@ -1369,8 +1370,8 @@ describe("Capability live runnerd and Codex session", () => { transportOptions: { acpxCandidateProfile: acpxAgent }, }); const session = await service.create({ - provider: "acpx", acpxAgent, requestedModel: "explicit-test-model", + provider: "acpx", acpxAgent, ...(acpxAgent === "pi" ? { piThinkingLevel: "low" as const } : {}), }); const result = await session.sendMessage("Orient to this task."); expect(result.status).toBe("completed"); diff --git a/packages/paperclip-runner/src/live/live-session.ts b/packages/paperclip-runner/src/live/live-session.ts index 78071fd47f..b79755a668 100644 --- a/packages/paperclip-runner/src/live/live-session.ts +++ b/packages/paperclip-runner/src/live/live-session.ts @@ -1,3 +1,5 @@ +import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js"; +import { resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js"; import { normalizeProviderNotice } from "../drivers/provider-notices.js"; import { liveRunResultFeedback } from "./run-result-feedback.js"; import { createHash, randomUUID } from "node:crypto"; @@ -134,6 +136,7 @@ export interface CapabilityLiveSessionConfigSnapshot { driver?: "codex_app_server" | "opencode_server" | "claude_managed_agents_api" | "aws_agentcore_harness_api" | "acpx_runtime"; providerVersion?: string | null; acpxAgent?: QualifiedAcpxAgent; + piThinkingLevel?: "off" | "low" | "high" | "max"; acpxProfile?: QualifiedAcpxProfile; managedProfile?: { profileId: string; @@ -324,6 +327,7 @@ export interface CreateCapabilityLiveSessionInput { workingDirectory?: string; provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx"; acpxAgent?: QualifiedAcpxAgent; + piThinkingLevel?: "off" | "low" | "high" | "max"; requestedModel?: string; managedProfile?: CapabilityLiveSessionConfigSnapshot["managedProfile"]; agentCoreProfile?: CapabilityLiveSessionConfigSnapshot["agentCoreProfile"]; @@ -648,6 +652,7 @@ export function assertCapabilityLiveSessionSnapshot( } else if (provider === "opencode" || provider === "claude_managed" || provider === "aws_agentcore" || provider === "acpx") { throw new Error(`capability_live_checkpoint_corrupt: missing ${provider === "opencode" ? "OpenCode" : provider === "claude_managed" ? "Claude Agent" : provider === "aws_agentcore" ? "AWS AgentCore" : "ACPX"} model`); } + resolvePiThinkingLevel(provider === "acpx" ? String(config.acpxAgent) : "", config.piThinkingLevel); if (provider === "acpx") { const agent = config.acpxAgent; if (agent !== "pi" && agent !== "claude" && agent !== "codex" && agent !== "grok" && agent !== "cursor" && agent !== "copilot") { @@ -901,8 +906,9 @@ export class CapabilityLiveSessionService { } async create(input: CreateCapabilityLiveSessionInput = {}): Promise { + resolvePiThinkingLevel(input.provider === "acpx" ? input.acpxAgent ?? "codex" : "", input.piThinkingLevel); if (input.provider === "acpx" && input.acpxAgent !== undefined - && ["pi", "copilot"].includes(input.acpxAgent) + && ACPX_CAPABILITY_PROFILES[input.acpxAgent].qualification === "pending" && this.#transportOptions.acpxCandidateProfile !== input.acpxAgent) { throw new Error("The candidate ACPX profile requires explicit evaluation opt-in"); } @@ -974,6 +980,7 @@ export class CapabilityLiveSessionService { : input.provider === "acpx" ? acpxProfile!.acpxVersion : null, ...(acpxProfile === null ? {} : { acpxAgent: acpxProfile.agent, + ...(acpxProfile.agent === "pi" ? { piThinkingLevel: resolvePiThinkingLevel("pi", input.piThinkingLevel) } : {}), acpxProfile: structuredClone(acpxProfile), }), ...(input.managedProfile === undefined @@ -2384,6 +2391,7 @@ export class CapabilityLiveSession { : {}), ...(provider === "acpx" && this.#config.acpxAgent ? { acpxAgent: this.#config.acpxAgent, + ...(this.#config.acpxAgent === "pi" ? { piThinkingLevel: this.#config.piThinkingLevel } : {}), } : {}), ...(provider === "claude_managed" && this.#config.managedProfile ? { diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index dc9b780385..a6b356c71a 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -1,3 +1,5 @@ +import { QUALIFIED_ACPX_PROFILES } from "../drivers/acpx/qualified-profiles.js"; +import { coldAdmissionTimeoutMs, waitForRunnerCommand } from "./runnerd-codex-transport.js"; import { chmod, cp, @@ -22,7 +24,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ControlPlanePort } from "../contracts/control-plane-port.js"; import { bindAcpxAgentFiles } from "../drivers/acpx/agent-files-binding.js"; import type { NativeExecutionInputV1 } from "../contracts/native-execution.js"; @@ -710,6 +712,9 @@ it("identifies an active provider turn that must stop before suspension", () => activeProviderTurnId: null, providerSettled: true, }); + expect(runnerdRecoveryInternals.providerDrainStateFromSnapshot({ + activeProviderTurnId: null, pendingEvents: [], providerExitUnconfirmed: true, + })).toEqual({ pendingEventCount: 0, activeProviderTurnId: null, providerSettled: false }); }); it.each([ @@ -720,6 +725,7 @@ it.each([ { pendingEvents: [], activeProviderTurnId: 1 }, { pendingEvents: [], activeTurnId: "" }, { pendingEvents: [], ambiguousTurnStartPending: "false" }, + { pendingEvents: [], providerExitUnconfirmed: "false" }, ])( "does not treat a malformed provider snapshot as drained (%j)", (snapshot) => { @@ -1885,12 +1891,10 @@ it.each([ denied: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN", "GH_TOKEN", "GITHUB_TOKEN", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"] }, { agent: "pi" as const, - allowed: ["OPENROUTER_API_KEY"], + allowed: ["OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "COPILOT_GITHUB_TOKEN"], denied: [ - "ANTHROPIC_API_KEY", - "CLAUDE_CODE_OAUTH_TOKEN", - "OPENAI_API_KEY", - "CODEX_API_KEY", + "CURSOR_API_KEY", "CURSOR_AUTH_TOKEN", "GH_TOKEN", "GITHUB_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", "CODEX_API_KEY", "PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET", ], }, @@ -2026,12 +2030,12 @@ it.each(["opencode", "acpx"] as const)( }, ); -it("admits Pi runnerd transport without candidate opt-in and keeps siblings gated", async () => { +it("admits Pi runnerd transport without candidate opt-in and keeps pending Copilot gated", async () => { const root = await mkdtemp(join(tmpdir(), "paperclip-pi-production-admission-")); - const { transport } = createCapabilityRunnerdCodexTransport({ provider: "acpx", acpxAgent: "pi", stateDirectory: root }); + const { transport } = createCapabilityRunnerdCodexTransport({ provider: "acpx", acpxAgent: "pi", piThinkingLevel: "low", stateDirectory: root }); try { await expect(transport.request("collaborationMode/list", {})).resolves.toMatchObject({ data: [{ mode: "plan" }] }); - for (const acpxAgent of ["cursor", "copilot"] as const) { + for (const acpxAgent of ["copilot"] as const) { expect(() => createCapabilityRunnerdCodexTransport({ provider: "acpx", acpxAgent, stateDirectory: root })) .toThrow("explicit evaluation opt-in"); } @@ -6304,6 +6308,75 @@ it("rotates PRP authority in place for a warm cross-run attachment", async () => } }, 30_000); +it("reopens Pi after a completed turn within its cold admission budget during warm attachment", async () => { + const root = await mkdtemp(join(tmpdir(), "runnerd-pi-warm-admission-")); + const cliRoot = join(root, "dist/cli"); + await mkdir(cliRoot, { recursive: true }); + const sidecarPath = join(cliRoot, "acpx-runtime-sidecar.cjs"); + const journal = join(root, "commands.ndjson"); + const fixture = await readFile(fileURLToPath(new URL("./fixtures/fake-pi-warm-sidecar.cjs", import.meta.url)), "utf8"); + await writeFile(sidecarPath, fixture.replace("/* fixture-config */ null", JSON.stringify({ journal, resumeDelayMs: 32_000, commandDigest: QUALIFIED_ACPX_PROFILES.pi.commandDigest }))); + const digest = (path: string) => `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`; + const bundle = createCapabilityRunnerdCodexTransport({ + provider: "acpx", acpxAgent: "pi", piThinkingLevel: "low", acpxPermissionMode: "deny-all", + runnerBinary: defaultCapabilityRunnerdBinary(), stateDirectory: root, runnerFilesystemRoot: root, + providerNodeCommand: process.execPath, providerNodeCommandSha256: digest(process.execPath), + acpxSidecarPath: sidecarPath, acpxSidecarSha256: digest(sidecarPath), + providerPackAuthorityDigest: `sha256:${"d".repeat(64)}`, + lifecyclePolicy: { mode: "warm", idleTimeoutMs: 60_000 }, + environment: { PATH: "/usr/bin:/bin" }, + }); + bundle.transport.setServerRequestHandler(async () => ({ + success: true, + contentItems: [{ type: "inputText", text: "Completion report accepted." }], + })); + let failure: unknown; + try { + await bundle.transport.request("initialize", {}); + await bundle.transport.request("thread/start", { + cwd: root, model: "openrouter/deepseek/deepseek-v4-flash-0731", + dynamicTools: codexSemanticToolSpecs(), + completionContract: { revision: "warm-pi-contract", criterionIds: [] }, + }); + const runnerPid = bundle.evidence().runnerPid; + const notifications = bundle.transport.notifications()[Symbol.asyncIterator](); + const completeTurn = async () => { + await bundle.transport.request("turn/start", { input: [{ type: "text", text: "Complete the fixture turn." }] }); + for (let index = 0; index < 64; index += 1) { + const next = await Promise.race([ + notifications.next(), + new Promise((_, reject) => setTimeout(() => reject(new Error("Fixture completion timed out")), 5_000)), + ]); + if (next.value?.method === "turn/completed") return; + } + throw new Error("Fixture turn did not complete"); + }; + await completeTurn(); + const started = Date.now(); + await bundle.transport.attachRun!({ runId: "run-pi-warm-second", turnId: "turn-pi-warm-second", itemId: "item-pi-warm-second" }); + expect(Date.now() - started).toBeGreaterThan(30_000); + await completeTurn(); + expect(bundle.evidence()).toMatchObject({ runnerPid, runnerExited: false }); + const commands = (await readFile(journal, "utf8")).trim().split("\n").map((line) => JSON.parse(line)); + expect(commands.filter((entry) => entry.event === "spawn")).toHaveLength(2); + expect(commands.filter((entry) => entry.command === "session.open")).toHaveLength(2); + expect(commands.filter((entry) => entry.resumed)).toHaveLength(1); + expect(commands.filter((entry) => entry.command === "turn.start")).toHaveLength(2); + expect(commands.filter((entry) => entry.command === "session.close")).toHaveLength(1); + } catch (error) { + failure = error; + throw error; + } finally { + try { + await bundle.transport.close(); + } catch (cleanupError) { + if (failure) throw new AggregateError([failure, cleanupError], "Warm attachment and strict cleanup failed"); + throw cleanupError; + } + await rm(root, { recursive: true, force: true }); + } +}, 75_000); + it("waits for a warm runner to re-authenticate before probing attachment readiness", async () => { const stateDirectory = await mkdtemp( join(tmpdir(), "runnerd-warm-reattach-before-probe-"), @@ -7483,12 +7556,14 @@ it("surfaces a runner exit while provider-ingress readiness is still pending", a it("rejects the notification stream promptly when runnerd exits after accepting a turn", async () => { const stateDirectory = await mkdtemp(join(tmpdir(), "runnerd-exit-stream-")); + const diagnostics: string[] = []; const bundle = createCapabilityRunnerdCodexTransport({ runnerBinary: defaultCapabilityRunnerdBinary(), codexCommand: fakeCodex, codexArgs: fakeCodexArgs(stateDirectory, "--linger-after-turn-start"), stateDirectory, closeGraceMs: 400, + onDiagnostic: (message) => diagnostics.push(message), }); bundle.transport.setServerRequestHandler(async () => ({ success: true, @@ -7543,6 +7618,12 @@ it("rejects the notification stream promptly when runnerd exits after accepting ), ); expect(runnerState.lifecycle).not.toBe("suspended"); + const settlement = diagnostics.find((message) => message.startsWith("native_session_settlement_incomplete ")); + expect(settlement).toBeDefined(); + expect(JSON.parse(settlement!.slice("native_session_settlement_incomplete ".length))).toMatchObject({ + runnerSuspended: false, + suspensionState: { commandStatus: "pending", runnerIdentityMatches: true }, + }); } finally { await rm(stateDirectory, { recursive: true, force: true }); } @@ -7813,10 +7894,83 @@ it("preserves prepared input and completion feedback through runnerd and the rea it.each([ - { provider: "codex", acpxAgent: "cursor", acpxCursorMode: "plan" }, - { provider: "acpx", acpxAgent: "copilot", acpxCursorMode: "plan" }, - { provider: "acpx", acpxAgent: "cursor", acpxCursorMode: "auto" }, -] as const)("rejects invalid Cursor mode transport options before allocating resources: %j", options => { + { provider: "codex", acpxAgent: "cursor", acpxMode: "plan" }, + { provider: "acpx", acpxAgent: "copilot", acpxMode: "" }, + { provider: "acpx", acpxAgent: "cursor", acpxMode: 3 }, +] as const)("rejects invalid provider mode transport options before allocating resources: %j", options => { expect(() => createCapabilityRunnerdCodexTransport(options as unknown as Parameters[0])) - .toThrow("acpxCursorMode"); + .toThrow(/acpxMode|Provider mode/); +}); + +it.each([undefined, "medium", "minimal", "xhigh", null])("rejects non-exact Pi transport thinking level %s before spawn", piThinkingLevel => { + expect(() => createCapabilityRunnerdCodexTransport({ provider: "acpx", acpxAgent: "pi", piThinkingLevel: piThinkingLevel as never })).toThrow(/thinking/); +}); +it("rejects Pi thinking settings on a different transport provider", () => { + expect(() => createCapabilityRunnerdCodexTransport({ provider: "codex", piThinkingLevel: "low" })).toThrow(/only supported/); +}); + + +describe("cold process admission budget", () => { + it("bounds Pi cold startup and recovery at 60 seconds", () => { + expect(coldAdmissionTimeoutMs("acpx", "pi", true)).toBe(60_000); + }); + it("preserves live adoption and every other provider at 30 seconds", () => { + expect(coldAdmissionTimeoutMs("acpx", "pi", false)).toBe(30_000); + for (const agent of ["codex", "claude", "cursor", "copilot", "grok", undefined]) { + expect(coldAdmissionTimeoutMs("acpx", agent, true)).toBe(30_000); + } + for (const provider of ["codex", "opencode", undefined]) { + expect(coldAdmissionTimeoutMs(provider, "pi", true)).toBe(30_000); + } + }); +}); + + +it("shares Pi's absolute cold deadline across recovery barriers and rejects a late ACK", async () => { + vi.useFakeTimers(); + try { + const deadline = Date.now() + coldAdmissionTimeoutMs("acpx", "pi", true); + let status = "pending"; + const wait = (type: string) => waitForRunnerCommand({ + type, deadline, abortOnClose: true, isClosed: () => false, + throwIfFailed: () => undefined, command: () => ({ status }), + runnerHasExited: async () => false, failureCode: () => "startup_failed", + }); + const attached = wait("run.attach"); + await vi.advanceTimersByTimeAsync(40_000); + status = "completed"; + await vi.advanceTimersByTimeAsync(10); + await attached; + status = "pending"; + const drain = wait("runner.drain"); + const rejection = expect(drain).rejects.toThrow("runner.drain timed out"); + await vi.advanceTimersByTimeAsync(20_000); + await rejection; + status = "completed"; + await expect(wait("session.open")).rejects.toThrow("session.open timed out"); + } finally { vi.useRealTimers(); } +}); + +it("aborts startup promptly on close while allowing the owned cleanup command to drain", async () => { + vi.useFakeTimers(); + try { + let closed = false; + let status = "pending"; + const input = { + deadline: Date.now() + 60_000, isClosed: () => closed, + throwIfFailed: () => undefined, command: () => ({ status }), + runnerHasExited: async () => false, failureCode: () => "startup_failed", + }; + const opening = waitForRunnerCommand({ ...input, type: "session.open", abortOnClose: true }); + const rejection = expect(opening).rejects.toThrow("closed while waiting for session.open"); + closed = true; + await vi.advanceTimersByTimeAsync(10); + await rejection; + // A late open ACK cannot revive the cancelled waiter, but cleanup still owns the process. + const drain = waitForRunnerCommand({ ...input, type: "runner.drain", abortOnClose: false }); + await vi.advanceTimersByTimeAsync(10); + status = "completed"; + await vi.advanceTimersByTimeAsync(10); + await drain; + } finally { vi.useRealTimers(); } }); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index ba6f3f3248..88993b2f35 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1,3 +1,5 @@ +import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js"; +import { admittedPiThinkingLevel, resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js"; import { configuredEnvironment } from "../configured-environment.js"; import { resolveAcpxProviderMode } from "../drivers/acpx/provider-mode.js"; import { isAcpxCanonicalInputMethod } from "../drivers/acpx/profile-extensions.js"; @@ -6,6 +8,7 @@ import { waitForWarmAttachmentReadiness } from "./warm-attachment-readiness.js"; import { codexExecutableReadOnlyRoots } from "../drivers/codex/codex-security-config.js"; import { isCanonicalProviderEventType } from "../provider-events.js"; import { execFileSync } from "node:child_process"; +import { readLinuxProcessStartedAt } from "./linux-process-start.js"; import { createHash, randomUUID } from "node:crypto"; import { appendFileSync, @@ -98,7 +101,7 @@ function readLocalProcessStartedAt(pid: number): string | null { if (!Number.isInteger(pid) || pid <= 0) return null; try { if (process.platform === "linux") { - return new Date(statSync(`/proc/${pid}`).ctimeMs).toISOString(); + return readLinuxProcessStartedAt(pid); } if ( ["darwin", "freebsd", "openbsd", "aix", "sunos"].includes( @@ -578,7 +581,9 @@ function providerDrainStateFromSnapshot(state: Record): { (typeof value !== "string" || value.length === 0), ) || (state.ambiguousTurnStartPending !== undefined && - typeof state.ambiguousTurnStartPending !== "boolean") + typeof state.ambiguousTurnStartPending !== "boolean") || + (state.providerExitUnconfirmed !== undefined && + typeof state.providerExitUnconfirmed !== "boolean") ) throw new Error("Provider drain state is malformed."); const pending = Array.isArray(state.pendingEvents) @@ -595,7 +600,8 @@ function providerDrainStateFromSnapshot(state: Record): { pendingEventCount: pending + queued, activeProviderTurnId, providerSettled: - activeProviderTurnId === null && state.ambiguousTurnStartPending !== true, + activeProviderTurnId === null && state.ambiguousTurnStartPending !== true + && state.providerExitUnconfirmed !== true, }; } @@ -922,8 +928,12 @@ export function bridgedAcpxPermissionParams( 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" + if (event.eventType !== "runtime_request.created") return null; + return acpxPermissionRequestParams(request, threadId, turnId); +} + +function acpxPermissionRequestParams(request: Record, threadId: string, turnId: string): Record | null { + if (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, @@ -942,6 +952,36 @@ export function bridgedAcpxPermissionParams( }; } +/** Rehydrate only the pending ledger attested by the same live ACP session. */ +export function liveAcpxRuntimeRequests(snapshot: Record, threadId: string, turnId: string, durableTurnId: string): CodexRpcServerRequest[] { + if (snapshot.runtimeRequestsLive !== true || snapshot.provider !== "acpx" + || snapshot.driverSessionId !== threadId || snapshot.activeProviderTurnId !== turnId + || snapshot.runtimeRequestTurnId !== durableTurnId || !durableTurnId + || !turnId || !Array.isArray(snapshot.pendingRuntimeRequests) + || snapshot.pendingRuntimeRequests.length > 1024) { + throw new Error("ACPX pending request snapshot binding is invalid"); + } + const ids = new Set(); + return snapshot.pendingRuntimeRequests.map(value => { + const request = record(value), origin = record(request.origin); + const id = request.requestId, method = origin.method; + if (typeof id !== "string" || !id || id.length > 160 || ids.has(id) + || request.schema !== "paperclip.runtime_request.v2" || request.status !== "pending" + || request.turnId !== durableTurnId || typeof method !== "string") { + throw new Error("ACPX pending request snapshot request is invalid"); + } + ids.add(id); + const permission = request.type === "permission" && request.requestKind === "permission_approval" + && method === "session/request_permission"; + const params = permission + ? acpxPermissionRequestParams(request, threadId, turnId) + : request.type === "input" && request.requestKind === "runtime" && isAcpxCanonicalInputMethod(method) + ? bridgedCodexQuestionParams(request, method, threadId, turnId) : null; + if (!params) throw new Error("ACPX pending request snapshot form is invalid"); + return { id, method, params }; + }); +} + export function bridgedCodexQuestionParams( request: Record, method: string, @@ -1137,6 +1177,8 @@ export interface CapabilityRunnerdProcessEvidence { agentPid: number | null; agentProcessStartedAt: string | null; providerDriver: string | null; + /** Effective native mode, populated only from the admitted Pi identity. */ + piThinkingLevel?: "off" | "low" | "high" | "max"; providerVersion: string | null; acpxAgent: QualifiedAcpxAgent | null; agentServerVersion: string | null; @@ -1160,6 +1202,7 @@ export interface CapabilityRunnerdCodexTransportOptions { acpxCandidateProfile?: "pi" | "cursor" | "copilot"; acpxPermissionMode?: NativeAcpxPermissionMode; acpxMode?: string; + piThinkingLevel?: "off" | "low" | "high" | "max"; acpxPermissionModePinned?: boolean; acpxSidecarPath?: string; /** SHA-256 verified by the provider-pack authority before runner startup. */ @@ -3521,9 +3564,11 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { readonly #traceFrameIndex = new RunnerdTraceFrameIndex(); #pendingTraceRehydrations: PendingTraceRehydration[] = []; #pendingDriverTraceInterpretations: PendingDriverTraceInterpretation[] = []; + #restoredRuntimeRequests: CodexRpcServerRequest[] = []; readonly #bridgedRuntimeInputs = new Map(); constructor(readonly options: CapabilityRunnerdCodexTransportOptions) { + resolvePiThinkingLevel(options.provider === "acpx" ? options.acpxAgent ?? "codex" : "", options.piThinkingLevel); if (options.acpxMode !== undefined && options.provider !== "acpx") { throw new Error("acpxMode requires the ACPX provider"); } @@ -3532,7 +3577,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { throw new Error("native_adopted_runner_state_directory_required"); } if (options.provider === "acpx" && options.acpxAgent !== undefined - && ["pi", "copilot"].includes(options.acpxAgent) + && ACPX_CAPABILITY_PROFILES[options.acpxAgent].qualification === "pending" && options.acpxCandidateProfile !== options.acpxAgent) { throw new Error("The candidate ACPX profile requires explicit evaluation opt-in"); } @@ -3721,7 +3766,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { // than reading its filesystem. This both supports remote process owners // and proves any identity restored after PRP event compaction before the // checkpoint-backed thread is exposed to the driver. - const snapshot = await this.#commandResult("session.snapshot", {}); + const restoreRuntimeRequests = this.#recoveryTurnBindingPending + && this.options.adoptExistingRunner !== undefined && this.options.provider === "acpx"; + const snapshot = await this.#commandResult("session.snapshot", restoreRuntimeRequests + ? { includePendingRuntimeRequests: true } : {}); this.#confirmCheckpointProviderIdentity( snapshot, "authenticated session.snapshot", @@ -3782,6 +3830,15 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { }); } } + if (restoreRuntimeRequests && activeProviderTurnId !== null) { + this.#restoredRuntimeRequests = liveAcpxRuntimeRequests(snapshot, this.#threadId, activeProviderTurnId, this.#durableTurnId); + for (const request of this.#restoredRuntimeRequests) { + this.#bridgedRuntimeInputs.set(String(request.id), { + durableTurnId: this.#durableTurnId, + permission: request.method === "session/request_permission", + }); + } + } // A controller can lose the checkpoint after a continuation is accepted. // Keep its prior terminal as the history anchor, so driver recovery can // adopt the later accepted turn instead of submitting it again. Items @@ -3879,6 +3936,14 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { return this.#queue; } + takeRestoredRuntimeRequests(): CodexRpcServerRequest[] { + this.#throwIfFailed(); + const requests = this.#restoredRuntimeRequests; + this.#restoredRuntimeRequests = []; + return requests.filter(request => this.#bridgedRuntimeInputs.has(String(request.id)) + && request.params.threadId === this.#threadId && request.params.turnId === this.#turnId); + } + setServerRequestHandler(handler: CodexServerRequestHandler): void { this.#handler = handler; } @@ -3998,7 +4063,16 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { paperclipNextAuthority: { identity: desired, connection }, }; core.queueCommand("run.attach", payload, commandId, true); - await this.#waitCommand("run.attach", commandId); + // ACPX run attachment checkpoints the old sidecar and starts a fresh + // provider process. Pi must use the same absolute cold-admission budget + // as startup/recovery even though its Runner authority remains warm. + // Other providers retain the ordinary command bound. + await this.#waitCommand( + "run.attach", + commandId, + this.#coldAdmissionDeadline(), + true, + ); const attached = core.getCommand(commandId); if (attached?.status !== "completed") { throw new Error("native_runner_prp_run_rotation_failed"); @@ -4186,7 +4260,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } } - async #stopActiveProviderTurnBeforeSuspend(deadline: number): Promise { + async #stopProviderBeforeSuspend(deadline: number): Promise { const state = this.#providerDrainState(); const core = this.#core; const inferredActiveProviderTurnId = @@ -4199,9 +4273,17 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { state !== null && state !== "unreadable" ? state.activeProviderTurnId : inferredActiveProviderTurnId; + // Pi's idle RPC close can consume the entire suspension reserve. Retire + // its already-settled process during preparation instead. Native turn.stop + // independently checks the idle ledger and inherited lifetime fence; + // drain and runner.suspend still prove exact durable settlement afterward. + const stopIdlePi = this.options.provider === "acpx" + && this.options.acpxAgent === "pi" + && state !== "unreadable" + && (state === null || (state.activeProviderTurnId === null && state.providerSettled)); if ( state === "unreadable" || - activeProviderTurnId === null || + (activeProviderTurnId === null && !stopIdlePi) || core === null ) { return; @@ -4220,7 +4302,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { ); if (command?.status === "completed") { this.#diagnostic( - `stopped active provider turn ${activeProviderTurnId} before runner suspension`, + activeProviderTurnId === null + ? "stopped idle Pi provider before runner suspension" + : `stopped active provider turn ${activeProviderTurnId} before runner suspension`, ); return; } @@ -4327,6 +4411,12 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { let runnerSuspended = false; let providerDrained = false; let suspensionRequired = false; + let lastSuspensionState: Record | null = null; + let suspensionState: { + commandStatus: string | null; + runnerLifecycle: string | null; + runnerIdentityMatches: boolean | null; + } | null = null; if ( this.#core !== null && (this.#handle !== null || adoptedRunner !== undefined) && @@ -4357,7 +4447,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { // trip may need to carry. Give both cases the same budget so a // slow-but-idle runner is not held to a tighter deadline than a // runner that just stopped a turn. - await this.#stopActiveProviderTurnBeforeSuspend(preparationDeadline); + await this.#stopProviderBeforeSuspend(preparationDeadline); providerDrained = await this.#drainSettledProviderEventsBeforeSuspend( Math.min(5_000, Math.max(0, preparationDeadline - Date.now())), ); @@ -4373,6 +4463,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { }, readRunnerState: async () => { const state = await this.#readDurableRunnerState(); + lastSuspensionState = state; assertSuspendedRunnerState(state, this.#core!.store.state.identity); return state; }, @@ -4381,6 +4472,21 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { deadline: closeDeadline, }); if (!runnerSuspended) { + const command = [...this.#core.store.state.commands].reverse().find( + (candidate) => candidate.type === "runner.suspend", + ); + // Reuse the barrier's last observation. Diagnostic reads must not + // extend the close deadline or depend on a now-unreachable remote root. + const state = lastSuspensionState as Record | null; + suspensionState = { + commandStatus: command?.status ?? null, + runnerLifecycle: state !== null && [ + "ready", "suspended", "closed", "recoverable_failure", + ].includes(String(state.lifecycle)) ? String(state.lifecycle) : null, + runnerIdentityMatches: state === null ? null + : state.schema === "paperclip.runner.durable.state.v1" + && recoveryIdentityMatches(state, this.#core.store.state.identity), + }; this.#diagnostic( "runner did not prove durable suspension before checkpoint", ); @@ -4491,6 +4597,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const settlement = { runnerSuspended, providerDrained, + suspensionState, semanticTools: this.#core?.semanticToolSettlementDiagnostics(), finalProviderState, }; @@ -4750,6 +4857,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { instructions: baseInstructions, providerPolicy: { readOnly: params.permissions === "paperclip-runner-workspace-read-only" }, ...(selectedAcpxMode === undefined ? {} : { mode: selectedAcpxMode }), + ...(acpxProfile!.agent === "pi" ? { piThinkingLevel: this.options.piThinkingLevel } : {}), permissionMode: resolveRunnerdAcpxPermissionMode( this.options.acpxPermissionMode, ), @@ -4886,6 +4994,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.#controlPlaneCheckpoint = registration.checkpoint ?? null; this.#controlPlaneRelease = registration.release; } + const admissionDeadline = this.#coldAdmissionDeadline(); const handle = spawnRunner({ connection: registration?.connection ?? { mode: "connect", @@ -4940,9 +5049,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.#evidence.runnerProcessGroupId = handle.processGroupId ?? null; this.#publish(); this.#pump = setInterval(() => this.#pumpEventsSafely(), 5); - await this.#waitCommand("run.prepare"); - await this.#waitCommand("session.open"); - await this.#waitForProviderIdentity(); + await this.#waitCommand("run.prepare", undefined, undefined, true); + await this.#waitCommand("session.open", undefined, admissionDeadline, true); + await this.#waitForProviderIdentity(undefined, admissionDeadline); this.#startupComplete = true; this.#diagnostic("runnerd authenticated to the durable PRP control plane"); return this.#openedThreadResponse(params); @@ -5506,6 +5615,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { if (warmRecovery) { await this.options.authorizeWarmTransitionRecovery?.("before_spawn"); } + // A replacement executor restores its cold provider before acknowledging recovery. + const admissionDeadline = this.#coldAdmissionDeadline(adoptedRunner === undefined); const handle = adoptedRunner ? null : spawnRunner({ @@ -5604,11 +5715,13 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { registration?.ready, ); } + // Only a replacement executor can be restoring a cold Pi process. Share + // one deadline across every recovery barrier; live adoption stays at 30 s. if (runAttachment) { - await this.#waitCommand("run.attach", runAttachment.commandId); + await this.#waitCommand("run.attach", runAttachment.commandId, admissionDeadline, true); } if (recoveryProbeCommandId !== null) { - await this.#waitCommand("runner.drain", recoveryProbeCommandId); + await this.#waitCommand("runner.drain", recoveryProbeCommandId, admissionDeadline, true); if (this.#checkpointProviderIdentityExpectation !== null) { // A replacement runner can restore the exact provider while its fresh // session.resumed event is compacted or delayed behind the completed @@ -5616,7 +5729,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { // waiting only on the bounded event replay. The authenticated command // result is still checked against the exact database checkpoint, so a // missing or changed provider identity continues to fail closed. - const snapshot = await this.#commandResult("session.snapshot", {}); + const snapshot = await this.#commandResult("session.snapshot", {}, admissionDeadline, true); this.#confirmCheckpointProviderIdentity( snapshot, "authenticated recovery session.snapshot", @@ -5634,6 +5747,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { !this.#checkpointProviderIdentityConfirmed ? "session.resumed" : undefined, + admissionDeadline, ); this.#startupComplete = true; this.#diagnostic( @@ -5901,12 +6015,13 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { type: string, payload: Record, deadline?: number, + abortOnClose = false, ): Promise> { const core = this.#core; if (core === null) throw new Error("PRP provider thread is not started"); const commandId = `command_lab_${randomUUID().replaceAll("-", "")}`; core.queueCommand(type, payload, commandId, true); - await this.#waitCommand(type, commandId, deadline); + await this.#waitCommand(type, commandId, deadline, abortOnClose); const command = core.getCommand(commandId); if (command?.status !== "completed" || command.type !== type) { throw new Error(`PRP command ${type} omitted its durable result`); @@ -5961,12 +6076,19 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { return result; } + #coldAdmissionDeadline(coldProcess = true): number | undefined { + const timeout = coldAdmissionTimeoutMs(this.options.provider, this.options.acpxAgent, coldProcess); + // Preserve the existing independent waits for other providers and live adoption. + return timeout === 60_000 ? Date.now() + timeout : undefined; + } + async #waitForProviderIdentity( expectedEventType?: "harness.ready" | "session.started" | "session.resumed", + deadline = Date.now() + 30_000, ): Promise { - const deadline = Date.now() + 30_000; while (Date.now() < deadline) { this.#throwIfFailed(); + if (this.#closed) throw new Error("runnerd transport closed during provider startup"); this.#pumpEvents(); if ( this.#threadId.length > 0 && @@ -5988,28 +6110,20 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { type: string, commandId?: string, deadline = Date.now() + 30_000, + abortOnClose = false, ): Promise { - while (Date.now() < deadline) { - this.#throwIfFailed(); - const command = - commandId === undefined - ? this.#core?.store.state.commands.find( - (candidate) => candidate.type === type, - ) - : this.#core?.getCommand(commandId); - if (command?.status === "completed") return; - if (command !== undefined && command.status !== "pending") { - throw new Error( - `PRP command ${type} ${command.status}: ${JSON.stringify(command.result)}`, - ); - } - if (await this.#runnerHasExited()) - throw new Error(`runnerd exited while waiting for ${type}`); - await new Promise((resolveWait) => setTimeout(resolveWait, 10)); - } - throw new Error( - `${this.#startupComplete ? "provider_transport_failed" : this.#startupFailureCode}: PRP command ${type} timed out`, - ); + await waitForRunnerCommand({ + type, + deadline, + abortOnClose, + isClosed: () => this.#closed, + throwIfFailed: () => this.#throwIfFailed(), + command: () => commandId === undefined + ? this.#core?.store.state.commands.find((candidate) => candidate.type === type) + : this.#core?.getCommand(commandId), + runnerHasExited: () => this.#runnerHasExited(), + failureCode: () => this.#startupComplete ? "provider_transport_failed" : this.#startupFailureCode, + }); } @@ -6419,6 +6533,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { if (descriptor.turnControls !== undefined && descriptor.agent !== (this.options.acpxAgent ?? "codex")) { throw new Error("ACPX capability identity differs from the admitted profile"); } + const piThinkingLevel = admittedPiThinkingLevel(this.options.acpxAgent ?? "codex", this.options.piThinkingLevel, descriptor, providerIdentity); + if (piThinkingLevel !== undefined) this.#evidence.piThinkingLevel = piThinkingLevel; this.#turnControls = parseAcpxTurnControlCapabilities(descriptor.turnControls, descriptor.agent); } if (pid !== null) { @@ -6957,3 +7073,37 @@ export function resolveRunnerdCodexSkillInputs( }; }); } + +/** Controller admission budget; ordinary command and turn deadlines are independent. */ +export function coldAdmissionTimeoutMs(provider: string | undefined, agent: string | undefined, coldProcess: boolean): number { + return coldProcess && provider === "acpx" && agent === "pi" ? 60_000 : 30_000; +} + +/** Shared polling path keeps startup cancellation separate from owned cleanup commands. */ +export async function waitForRunnerCommand(input: { + type: string; + deadline: number; + abortOnClose: boolean; + isClosed: () => boolean; + throwIfFailed: () => void; + command: () => { status: string; result?: unknown } | undefined; + runnerHasExited: () => Promise; + failureCode: () => string; +}): Promise { + while (Date.now() < input.deadline) { + input.throwIfFailed(); + if (input.abortOnClose && input.isClosed()) { + throw new Error(`runnerd transport closed while waiting for ${input.type}`); + } + const command = input.command(); + if (command?.status === "completed") return; + if (command !== undefined && command.status !== "pending") { + throw new Error(`PRP command ${input.type} ${command.status}: ${JSON.stringify(command.result)}`); + } + if (await input.runnerHasExited()) { + throw new Error(`runnerd exited while waiting for ${input.type}`); + } + await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + } + throw new Error(`${input.failureCode()}: PRP command ${input.type} timed out`); +} diff --git a/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts index 40127cfbd8..9d9efa46ac 100644 --- a/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts +++ b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts @@ -1170,9 +1170,37 @@ export const providerDescriptorSchema = { }, "turnControls": { "$ref": "#/$defs/turnControls" + }, + "piThinkingLevel": { + "enum": [ + "off", + "low", + "high", + "max" + ] } }, "allOf": [ + { + "if": { + "required": [ + "piThinkingLevel" + ] + }, + "then": { + "properties": { + "provider": { + "const": "acpx" + }, + "agent": { + "const": "pi" + } + }, + "required": [ + "agent" + ] + } + }, { "oneOf": [ { diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs b/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs index eea087d93f..7e3209eae4 100644 --- a/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs +++ b/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs @@ -1,6 +1,6 @@ // Protocol fixture: no provider network calls and no secrets. import { createHash } from "node:crypto"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { createInterface } from "node:readline"; const home = process.env.PI_CODING_AGENT_DIR; @@ -56,6 +56,9 @@ const begin = () => { modelIteration++; output({ type: "turn_start" }); messageTimestamp++; assistantActive = true; output({ type: "message_start", message: nativeMessage() }); }; let model = "fixture-model"; +const thinkingFile = join(home, "thinking.json"); +let thinkingLevel = existsSync(thinkingFile) ? JSON.parse(readFileSync(thinkingFile, "utf8")) : process.env.PI_FIXTURE_THINKING_CURRENT ?? "off"; +const availableThinkingLevels = () => model === "fixture-alternate" ? ["off", "high"] : ["off", "low", "high", "max"]; const compaction = () => ({ firstKeptEntryId: "fixture-entry", tokensBefore: 50, summary: "Fixture compacted", usage: { input: 5, output: 2, cacheRead: 1, cacheWrite: 0, cost: { total: 0.02 } } }); const finish = (answer = "done") => { if (!assistantActive) { output({ type: "turn_end" }); begin(); } @@ -70,9 +73,19 @@ createInterface({ input: process.stdin }).on("line", (line) => { const request = JSON.parse(line); const response = (data = {}) => output({ type: "response", id: request.id, command: request.type, success: true, data }); if (request.type === "extension_ui_response") { finish(JSON.stringify(request)); return; } - if (request.type === "get_state") { response({ sessionId: "fixture-session", sessionFile, model: { provider: "openrouter", id: model }, thinkingLevel: "off" }); return; } - if (request.type === "get_available_models") { response({ models: [{ provider: "openrouter", id: "fixture-model", name: "Fixture" }] }); return; } - if (request.type === "set_model") { model = request.modelId; response({ model: { provider: request.provider, id: model } }); return; } + if (request.type === "get_state") { response({ sessionId: "fixture-session", sessionFile, model: { provider: "openrouter", id: model }, thinkingLevel }); return; } + if (request.type === "get_available_thinking_levels") { + const scenario = process.env.PI_FIXTURE_THINKING_CAPABILITIES; + if (scenario === "failed") { output({ type: "response", id: request.id, command: request.type, success: false, error: "fixture capability failure" }); return; } + response({ levels: scenario === "duplicate" ? ["off", "off"] : scenario === "unknown" ? ["off", "future"] : scenario === "empty" ? [] : scenario === "missing" ? undefined : availableThinkingLevels() }); return; + } + if (request.type === "set_thinking_level") { + appendFileSync(join(home, "thinking-calls.jsonl"), JSON.stringify(request.level) + "\n"); + thinkingLevel = process.env.PI_FIXTURE_THINKING_CLAMP === "1" ? "high" : request.level; + writeFileSync(thinkingFile, JSON.stringify(thinkingLevel)); response(); return; + } + if (request.type === "get_available_models") { response({ models: [{ provider: "openrouter", id: "fixture-model", name: "Fixture" }, { provider: "openrouter", id: "fixture-alternate", name: "Alternate" }] }); return; } + if (request.type === "set_model") { model = request.modelId; if (!availableThinkingLevels().includes(thinkingLevel)) thinkingLevel = "high"; response({ model: { provider: request.provider, id: model } }); return; } if (request.type === "get_messages") { response({ messages: process.env.PI_FIXTURE_HISTORY ? [ { role: "toolResult", toolName: "mcp__paperclip__paperclip_finish", toolCallId: "call_0", content: [{ type: "text", text: "correct criteria" }], isError: true }, { role: "toolResult", toolName: "mcp__paperclip__paperclip_finish", toolCallId: "call_0", content: [{ type: "text", text: "accepted" }] }, @@ -83,7 +96,7 @@ createInterface({ input: process.stdin }).on("line", (line) => { if (request.type === "steer") { response(inputDisposition); if (inputDisposition.disposition !== "queued") return; output({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: `steered:${request.message}` } }); return; } if (request.type === "follow_up") { response(inputDisposition); if (inputDisposition.disposition !== "queued") return; finish(`follow-up:${request.message}`); return; } if (request.type === "compact") { output({ type: "compaction_start", reason: "manual" }); const result = compaction(); output({ type: "compaction_end", reason: "manual", result, aborted: false, willRetry: false }); response(result); return; } - if (request.type === "abort") { response(); if (active) { active = false; if (assistantActive) endMessage("aborted", {}); output({ type: "turn_end" }); output({ type: "agent_settled" }); } return; } + if (request.type === "abort") { response(); if (active) { active = false; if (assistantActive) endMessage(process.env.PI_FIXTURE_CANCEL_ERROR === "1" ? "error" : "aborted", process.env.PI_FIXTURE_CANCEL_ERROR === "1" ? { input: 11, output: 3 } : {}); output({ type: "turn_end" }); output({ type: "agent_settled" }); } return; } if (request.type === "prompt") { response({ disposition: "started" }); active = true; output({ type: "agent_start" }); if (request.message === "missing-message-start") { modelIteration++; output({ type: "turn_start" }); output({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "invalid" } }); return; } diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v13-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v13-identity.json new file mode 100644 index 0000000000..8eb2c022cb --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v13-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:fe1e6da01b2a9e4c691ca27cf689d2d6de846a93be6b23fc1e103c9addd7b177", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 13, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "f6538a35e08f1c1816e738277a1843acfa1ce4522bbaa3340dbddd859dc7dd04", + "darwin-x64": "4728e5a4e7fc1ba602c3e219824fb84884b05a06cdd19dd3e521ee312e1b373a", + "linux-x64": "2957c0ec20ca1ace64d1a2b10c4a99f47f59e0c5c33a89161c1d5b48341c2b25" + }, + "wrapperSha256": "8ef777e9e6a3cef37775bab2b800e4931330c23a0b151ce07fdc5b943f28435d", + "helperSha256": "2191b1e5f281d24508ec7eaff39011ee863a8a70bf54f4aa0490ad0172b323da", + "extensionSha256": "8f9b538dbe924a314099f9597ee3876f7ccd88211c4f4b2e52f46b6c7ffa76ba", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v2", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e", + "nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts", + "nativeNotices": "session-extension-v2-bounded-runtime-failure", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativeInputDisposition": "queued-only-v1", + "cacheWarming": "disabled-owned-decision-v1", + "thinkingLevel": { + "supported": [ + "off", + "low", + "high", + "max" + ], + "newConfigurationDefault": "low", + "admission": "explicit-native-effective-before-prompt-v1", + "recovery": "mode-bound-v1" + } + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v14-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v14-identity.json new file mode 100644 index 0000000000..35314d2ed1 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v14-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:f35145437eeb355ed37bc5a4fa93d7ede561d9c45daf311979b46890b808ddd4", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 14, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "f6538a35e08f1c1816e738277a1843acfa1ce4522bbaa3340dbddd859dc7dd04", + "darwin-x64": "4728e5a4e7fc1ba602c3e219824fb84884b05a06cdd19dd3e521ee312e1b373a", + "linux-x64": "2957c0ec20ca1ace64d1a2b10c4a99f47f59e0c5c33a89161c1d5b48341c2b25" + }, + "wrapperSha256": "8ef777e9e6a3cef37775bab2b800e4931330c23a0b151ce07fdc5b943f28435d", + "helperSha256": "2191b1e5f281d24508ec7eaff39011ee863a8a70bf54f4aa0490ad0172b323da", + "extensionSha256": "8f9b538dbe924a314099f9597ee3876f7ccd88211c4f4b2e52f46b6c7ffa76ba", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v2", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "c0139d0b3af085ec5272a7812c9f89aff0e8618d1d44d9c9e9a59525d1972436", + "nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts", + "nativeNotices": "session-extension-v2-bounded-runtime-failure", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativeInputDisposition": "queued-only-v1", + "cacheWarming": "disabled-owned-decision-v1", + "thinkingLevel": { + "supported": [ + "off", + "low", + "high", + "max" + ], + "newConfigurationDefault": "low", + "admission": "explicit-native-effective-before-prompt-v1", + "recovery": "mode-bound-v1" + } + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v15-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v15-identity.json new file mode 100644 index 0000000000..685d8fb3e7 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v15-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:790f8b954be995ef63aef0ebdb0e06215e4c0d1416d40d605b33966a3d6ba053", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 15, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "729947513854dadc2d596a34186f7f19beaffead0fc97698d8eeac96b8479533", + "darwin-x64": "80de2913634f83e958792ddebc17e94f6dbbe5aa9aed3cea3ff5b40f44c90a59", + "linux-x64": "5fedea5f04f4f76d0e2ead0637b00322583d6c859f9df4f4dc8b332aca416eda" + }, + "wrapperSha256": "8ef777e9e6a3cef37775bab2b800e4931330c23a0b151ce07fdc5b943f28435d", + "helperSha256": "2191b1e5f281d24508ec7eaff39011ee863a8a70bf54f4aa0490ad0172b323da", + "extensionSha256": "71a85bf2e736d4108ea88a4aed01df3f547e8e4ccdeadea8251220e40cb05394", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v3-method-specific-fields", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "c0139d0b3af085ec5272a7812c9f89aff0e8618d1d44d9c9e9a59525d1972436", + "nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts", + "nativeNotices": "session-extension-v2-bounded-runtime-failure", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativeInputDisposition": "queued-only-v1", + "cacheWarming": "disabled-owned-decision-v1", + "thinkingLevel": { + "supported": [ + "off", + "low", + "high", + "max" + ], + "newConfigurationDefault": "low", + "admission": "explicit-native-effective-before-prompt-v1", + "recovery": "mode-bound-v1" + } + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v16-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v16-identity.json new file mode 100644 index 0000000000..cbcd725767 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v16-identity.json @@ -0,0 +1,48 @@ +{ + "commandDigest": "sha256:28eefeccb2556d2668e20aee2f12a90d1fef50b9be954d5f6dd97c757e2e945e", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 16, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "729947513854dadc2d596a34186f7f19beaffead0fc97698d8eeac96b8479533", + "darwin-x64": "80de2913634f83e958792ddebc17e94f6dbbe5aa9aed3cea3ff5b40f44c90a59", + "linux-x64": "5fedea5f04f4f76d0e2ead0637b00322583d6c859f9df4f4dc8b332aca416eda" + }, + "wrapperSha256": "8ef777e9e6a3cef37775bab2b800e4931330c23a0b151ce07fdc5b943f28435d", + "helperSha256": "2191b1e5f281d24508ec7eaff39011ee863a8a70bf54f4aa0490ad0172b323da", + "extensionSha256": "71a85bf2e736d4108ea88a4aed01df3f547e8e4ccdeadea8251220e40cb05394", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v3-method-specific-fields", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts", + "nativeNotices": "session-extension-v2-bounded-runtime-failure", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativeInputDisposition": "queued-only-v1", + "cacheWarming": "disabled-owned-decision-v1", + "thinkingLevel": { + "supported": [ + "off", + "low", + "high", + "max" + ], + "newConfigurationDefault": "low", + "admission": "explicit-native-effective-before-prompt-v1", + "recovery": "mode-bound-v1" + }, + "modelSelection": "caller-selected-native-verified-v1", + "providerConfigurationSourceSha256": "e04480cc8d1272a3421313ddf9f973325e8c5c83bd29b84cff589fd734e780c2", + "credentialEnvironmentSourceSha256": "4ee0e9ac56fab8889a4d06b98634bb1797aeda64fcb31e0a404829018c7a12cb", + "runtimeSandboxSourceSha256": "21e1ed2d132886260cf17f956dc21824948bd428b8a0aa05518c508017e66213", + "recoveryIdentitySourceSha256": "f333a24b19ef1b5b5d23ea9778e753f7051690e835ef9e4476a962d8a231c375" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v17-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v17-identity.json new file mode 100644 index 0000000000..85d8e82f8b --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v17-identity.json @@ -0,0 +1,48 @@ +{ + "commandDigest": "sha256:a1d976c437cb736c9cce8ebe8b8baf59e571761a8d00e8b1885e72dd906d8f21", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 17, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "729947513854dadc2d596a34186f7f19beaffead0fc97698d8eeac96b8479533", + "darwin-x64": "80de2913634f83e958792ddebc17e94f6dbbe5aa9aed3cea3ff5b40f44c90a59", + "linux-x64": "5fedea5f04f4f76d0e2ead0637b00322583d6c859f9df4f4dc8b332aca416eda" + }, + "wrapperSha256": "8ef777e9e6a3cef37775bab2b800e4931330c23a0b151ce07fdc5b943f28435d", + "helperSha256": "2191b1e5f281d24508ec7eaff39011ee863a8a70bf54f4aa0490ad0172b323da", + "extensionSha256": "71a85bf2e736d4108ea88a4aed01df3f547e8e4ccdeadea8251220e40cb05394", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v3-method-specific-fields", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts", + "nativeNotices": "session-extension-v2-bounded-runtime-failure", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativeInputDisposition": "queued-only-v1", + "cacheWarming": "disabled-owned-decision-v1", + "thinkingLevel": { + "supported": [ + "off", + "low", + "high", + "max" + ], + "newConfigurationDefault": "low", + "admission": "explicit-native-effective-before-prompt-v1", + "recovery": "mode-bound-v1" + }, + "modelSelection": "caller-selected-native-verified-v1", + "providerConfigurationSourceSha256": "2735ec4b46929f4f59c26cea42e623dfc81016aa7da7bd8399a24caa68a75d6b", + "credentialEnvironmentSourceSha256": "4ee0e9ac56fab8889a4d06b98634bb1797aeda64fcb31e0a404829018c7a12cb", + "runtimeSandboxSourceSha256": "21e1ed2d132886260cf17f956dc21824948bd428b8a0aa05518c508017e66213", + "recoveryIdentitySourceSha256": "f333a24b19ef1b5b5d23ea9778e753f7051690e835ef9e4476a962d8a231c375" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v18-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v18-identity.json new file mode 100644 index 0000000000..aced3bc066 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v18-identity.json @@ -0,0 +1,49 @@ +{ + "commandDigest": "sha256:9d3e7d8269f1a0af94616552dfc69671932688b81dbbd5bab9ef356b3a9cbccb", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 18, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "8b85caad950fc720eabd80d7b67146b5b2106c66aecf1797993b90a3568ddc7f", + "darwin-x64": "c66bfff68705627b3c5589e038080b284a04027a0a0a3371a54a657a80787789", + "linux-x64": "a1b2797c5ca8245b441b0cc0e18f09463d9d896bd52631874320d77a09a4736d" + }, + "wrapperSha256": "9d129b3d38772e93e97080aa6c4e574ac5df4e47ce5bc331a484a9fbf8188b36", + "helperSha256": "41e0490b617da0d60e0c8ec58ef311236f945129d99f816cff6897f78da7f91d", + "extensionSha256": "71a85bf2e736d4108ea88a4aed01df3f547e8e4ccdeadea8251220e40cb05394", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v3-method-specific-fields", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts", + "nativeNotices": "session-extension-v2-bounded-runtime-failure", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativeInputDisposition": "queued-only-v1", + "cacheWarming": "disabled-owned-decision-v1", + "thinkingLevel": { + "supported": [ + "off", + "low", + "high", + "max" + ], + "newConfigurationDefault": "low", + "admission": "explicit-native-effective-before-prompt-v1", + "recovery": "mode-bound-v1" + }, + "modelSelection": "caller-selected-native-verified-v1", + "providerConfigurationSourceSha256": "2735ec4b46929f4f59c26cea42e623dfc81016aa7da7bd8399a24caa68a75d6b", + "credentialEnvironmentSourceSha256": "4ee0e9ac56fab8889a4d06b98634bb1797aeda64fcb31e0a404829018c7a12cb", + "runtimeSandboxSourceSha256": "21e1ed2d132886260cf17f956dc21824948bd428b8a0aa05518c508017e66213", + "recoveryIdentitySourceSha256": "f333a24b19ef1b5b5d23ea9778e753f7051690e835ef9e4476a962d8a231c375", + "nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v19-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v19-identity.json new file mode 100644 index 0000000000..0dbc04a042 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v19-identity.json @@ -0,0 +1,49 @@ +{ + "commandDigest": "sha256:b7647ebf97f802ca053ec3384c912bf0e8d18eba308d27397bb1d95a37220825", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 19, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "e076276c674dfffccbb488883e18571d77d7225d801fabf5c8391773ddbbfc6c", + "darwin-x64": "eafd44e672dec4966ef6f038620f003e5d492c889d475886cd66be2e9c2bff1d", + "linux-x64": "f493df174cfecba3c31c524aa486ae37663cd68f8fcc0d9556e3f615c4a40ce9" + }, + "wrapperSha256": "9d129b3d38772e93e97080aa6c4e574ac5df4e47ce5bc331a484a9fbf8188b36", + "helperSha256": "41e0490b617da0d60e0c8ec58ef311236f945129d99f816cff6897f78da7f91d", + "extensionSha256": "f5818573a355702388072b28926897db546a5cf346b5171f646582ce5f9102a4", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v4-root-field-types", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts", + "nativeNotices": "session-extension-v2-bounded-runtime-failure", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativeInputDisposition": "queued-only-v1", + "cacheWarming": "disabled-owned-decision-v1", + "thinkingLevel": { + "supported": [ + "off", + "low", + "high", + "max" + ], + "newConfigurationDefault": "low", + "admission": "explicit-native-effective-before-prompt-v1", + "recovery": "mode-bound-v1" + }, + "modelSelection": "caller-selected-native-verified-v1", + "providerConfigurationSourceSha256": "2735ec4b46929f4f59c26cea42e623dfc81016aa7da7bd8399a24caa68a75d6b", + "credentialEnvironmentSourceSha256": "4ee0e9ac56fab8889a4d06b98634bb1797aeda64fcb31e0a404829018c7a12cb", + "runtimeSandboxSourceSha256": "21e1ed2d132886260cf17f956dc21824948bd428b8a0aa05518c508017e66213", + "recoveryIdentitySourceSha256": "f333a24b19ef1b5b5d23ea9778e753f7051690e835ef9e4476a962d8a231c375", + "nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v20-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v20-identity.json new file mode 100644 index 0000000000..92aeb214ed --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v20-identity.json @@ -0,0 +1,49 @@ +{ + "commandDigest": "sha256:465ae72460f05cae961873f09cd7cd3652dc3a6949930b7ee16303925cd3dd0e", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 20, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "e076276c674dfffccbb488883e18571d77d7225d801fabf5c8391773ddbbfc6c", + "darwin-x64": "eafd44e672dec4966ef6f038620f003e5d492c889d475886cd66be2e9c2bff1d", + "linux-x64": "f493df174cfecba3c31c524aa486ae37663cd68f8fcc0d9556e3f615c4a40ce9" + }, + "wrapperSha256": "9d129b3d38772e93e97080aa6c4e574ac5df4e47ce5bc331a484a9fbf8188b36", + "helperSha256": "41e0490b617da0d60e0c8ec58ef311236f945129d99f816cff6897f78da7f91d", + "extensionSha256": "f5818573a355702388072b28926897db546a5cf346b5171f646582ce5f9102a4", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v4-root-field-types", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts", + "nativeNotices": "session-extension-v2-bounded-runtime-failure", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativeInputDisposition": "queued-only-v1", + "cacheWarming": "disabled-owned-decision-v1", + "thinkingLevel": { + "supported": [ + "off", + "low", + "high", + "max" + ], + "newConfigurationDefault": "low", + "admission": "explicit-native-effective-before-prompt-v1", + "recovery": "mode-bound-v1" + }, + "modelSelection": "caller-selected-native-verified-v1", + "providerConfigurationSourceSha256": "2735ec4b46929f4f59c26cea42e623dfc81016aa7da7bd8399a24caa68a75d6b", + "credentialEnvironmentSourceSha256": "4ee0e9ac56fab8889a4d06b98634bb1797aeda64fcb31e0a404829018c7a12cb", + "runtimeSandboxSourceSha256": "1c1ddb0b24c3417250ce161cda897ba59b1adce7c7609ed553be298464e6c1c4", + "recoveryIdentitySourceSha256": "f333a24b19ef1b5b5d23ea9778e753f7051690e835ef9e4476a962d8a231c375", + "nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1" + } +} diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/reserved-credential-names.json b/packages/paperclip-runner/test-fixtures/pi-acp/reserved-credential-names.json new file mode 100644 index 0000000000..5f2dd1c718 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/reserved-credential-names.json @@ -0,0 +1,42 @@ +[ + "PATH", + "HOME", + "SHELL", + "TMPDIR", + "BASH_ENV", + "ENV", + "ZDOTDIR", + "LD_AUDIT", + "LD_LIBRARY_PATH", + "LD_PRELOAD", + "LD_DEBUG", + "LD_DEBUG_OUTPUT", + "LD_PROFILE", + "LD_PROFILE_OUTPUT", + "LD_TRACE_LOADED_OBJECTS", + "LD_ORIGIN_PATH", + "LD_BIND_NOW", + "LD_BIND_NOT", + "LD_DYNAMIC_WEAK", + "LD_HWCAP_MASK", + "LD_SHOW_AUXV", + "LD_USE_LOAD_BIAS", + "LD_VERBOSE", + "LD_WARN", + "LD_ASSUME_KERNEL", + "LD_PREFER_MAP_32BIT_EXEC", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "DYLD_FRAMEWORK_PATH", + "DYLD_FALLBACK_LIBRARY_PATH", + "DYLD_FALLBACK_FRAMEWORK_PATH", + "DYLD_VERSIONED_LIBRARY_PATH", + "DYLD_VERSIONED_FRAMEWORK_PATH", + "DYLD_ROOT_PATH", + "DYLD_IMAGE_SUFFIX", + "DYLD_SHARED_CACHE_DIR", + "GLIBC_TUNABLES", + "GCONV_PATH", + "LOCPATH", + "NLSPATH" +] diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/thinking-modes.v13.darwin-arm64.json b/packages/paperclip-runner/test-fixtures/pi-acp/thinking-modes.v13.darwin-arm64.json new file mode 100644 index 0000000000..b0f15e72f6 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/thinking-modes.v13.darwin-arm64.json @@ -0,0 +1,177 @@ +{ + "schema": "paperclip.pi-thinking-mode-proof.v1", + "status": "provider-free-compatibility-passed-not-paid-qualification", + "sourceBase": "b70cf84ea6cb20095aaeebc6af7f025ee6fdce73", + "profileDigest": "sha256:fe1e6da01b2a9e4c691ca27cf689d2d6de846a93be6b23fc1e103c9addd7b177", + "piVersion": "1.0.0", + "wrapperVersion": "0.0.33", + "platform": "darwin-arm64", + "patchSha256": "8b3dbb7e08c6c356e81624b9afcab13827269579cef085db15fdec0ef72aedb3", + "wrapperSha256": "8ef777e9e6a3cef37775bab2b800e4931330c23a0b151ce07fdc5b943f28435d", + "helperSha256": "2191b1e5f281d24508ec7eaff39011ee863a8a70bf54f4aa0490ad0172b323da", + "testFixtureSha256": "cd66c8afb34a6fc54ead7dd939470b8f01344914d0513db58d3f6e816140a45d", + "privateReceiptSha256": "a2e03c0ee4a07491ee5efda8e9dc251f88d52fcbb3d845b16f13376ec345bee3", + "externalProviderCalls": 0, + "syntheticLoopbackPrompts": 2, + "afterModelSelection": { + "type": "select", + "id": "thought_level", + "category": "thought_level", + "name": "Thinking", + "description": "Set the reasoning effort for this session", + "currentValue": "high", + "options": [ + { + "value": "off", + "name": "Thinking: off", + "description": null + }, + { + "value": "low", + "name": "Thinking: low", + "description": null + }, + { + "value": "high", + "name": "Thinking: high", + "description": null + }, + { + "value": "max", + "name": "Thinking: max", + "description": null + } + ] + }, + "lowAcknowledgement": { + "type": "select", + "id": "thought_level", + "category": "thought_level", + "name": "Thinking", + "description": "Set the reasoning effort for this session", + "currentValue": "low", + "options": [ + { + "value": "off", + "name": "Thinking: off", + "description": null + }, + { + "value": "low", + "name": "Thinking: low", + "description": null + }, + { + "value": "high", + "name": "Thinking: high", + "description": null + }, + { + "value": "max", + "name": "Thinking: max", + "description": null + } + ] + }, + "requestBodies": [ + { + "model": "deepseek/deepseek-v4-flash-0731", + "reasoning": { + "effort": "high" + } + }, + { + "model": "deepseek/deepseek-v4-flash-0731", + "reasoning": { + "effort": "low" + } + } + ], + "loadedModes": { + "currentModeId": "max", + "availableModes": [ + { + "id": "off", + "name": "Thinking: off", + "description": null + }, + { + "id": "low", + "name": "Thinking: low", + "description": null + }, + { + "id": "high", + "name": "Thinking: high", + "description": null + }, + { + "id": "max", + "name": "Thinking: max", + "description": null + } + ] + }, + "loadedThoughtLevel": { + "type": "select", + "id": "thought_level", + "category": "thought_level", + "name": "Thinking", + "description": "Set the reasoning effort for this session", + "currentValue": "max", + "options": [ + { + "value": "off", + "name": "Thinking: off", + "description": null + }, + { + "value": "low", + "name": "Thinking: low", + "description": null + }, + { + "value": "high", + "name": "Thinking: high", + "description": null + }, + { + "value": "max", + "name": "Thinking: max", + "description": null + } + ] + }, + "modeUpdates": [ + "high", + "low", + "max" + ], + "networkDenial": { + "deniedBeforeConnect": true, + "underlyingConnections": 0 + }, + "retirement": { + "allOwnedChildrenClosed": true, + "spawnErrors": 0, + "count": 2 + }, + "scratchRemoved": true, + "limits": [ + "The actual pinned Pi SDK/CLI and patched ACP wrapper were exercised against guarded loopback synthetic model responses.", + "This proves mode propagation and effective state, not paid model behavior, final platform packaging, complete runner admission or production readiness.", + "The prior paid question HTTP body was not captured. Its timeout cause remains unproven.", + "Three earlier private fixture teardown failures are retained and not counted as passes." + ], + "maintainedRegression": { + "path": "packages/paperclip-runner/test/pi-native-package-contract.test.mjs", + "sha256": "8a03f65dd5bc52634d683e2fa6157ae53c85063ac91647d1b936cdbd13b58a38", + "testName": "real Pi 1 serialized RPC thinking-modes through owned ACP/MCP bridge" + }, + "focusedSuites": { + "wrapper": 48, + "nativeSdk": 13, + "skips": 0, + "externalProviderCalls": 0 + } +} diff --git a/packages/paperclip-runner/test/acpx-rich-extensions-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-rich-extensions-package-contract.test.mjs index 103557b497..50bfcd016a 100644 --- a/packages/paperclip-runner/test/acpx-rich-extensions-package-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-rich-extensions-package-contract.test.mjs @@ -25,6 +25,8 @@ readline.createInterface({input:process.stdin}).on('line',(line)=>{ send({method:'fixture/ignored',params:{sessionId:'session-1'}}); send({method:'fixture/activity',params:{sessionId:'wrong-session'}}); send({id:0,method:mode==='unknown'?'fixture/not-enabled':'fixture/question',params:{sessionId:mode==='wrong-session'?'wrong-session':'session-1',value:'private-question'}}); + } else if(message.method==='pi/steer' || message.method==='pi/follow_up') { + send({id:message.id,result:{accepted:true,sessionId:message.params.sessionId,disposition:'queued',message:message.params.message}}); } else if(message.method==='session/cancel') { send({id:promptId,result:{stopReason:'cancelled'}}); promptId=undefined; } else if(message.id===0 && !message.method) { send({method:'session/update',params:{sessionId:'session-1',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:JSON.stringify(message)}}}}); @@ -77,6 +79,29 @@ test("initialize retains mandatory capabilities while merging provider metadata" }); }); +test("real patched client sends Pi steering and follow-up while its original prompt awaits input", { timeout: 10000 }, async () => { + let observe; const started = new Promise(resolve => { observe = resolve; }); + await withClient({ onExtensionRequest: async () => { observe(); return await new Promise(() => {}); } }, async client => { + const pending = client.prompt("session-1", "question"); + await started; + try { + for (const method of ["pi/steer", "pi/follow_up"]) { + assert.deepEqual(await client.requestExtension(method, { sessionId: "session-1", message: "Keep the original turn" }), { + accepted: true, sessionId: "session-1", disposition: "queued", message: "Keep the original turn", + }); + } + for (const method of ["session/prompt", "fs/read_text_file", "initialize", "bad method/name"]) { + await assert.rejects(client.requestExtension(method, {}), /extension method/); + } + await assert.rejects(client.requestExtension("pi/steer", []), /payload must be an object/); + await assert.rejects(client.requestExtension("pi/steer", { message: "x".repeat(256 * 1024) }), /bounded size/); + } finally { + await client.cancel("session-1"); + assert.equal((await pending).stopReason, "cancelled"); + } + }); +}); + test("unknown methods and cross-session requests never reach the host callback", { timeout: 10000 }, async () => { let calls = 0; await withClient({ onExtensionRequest: async () => { calls++; return {}; } }, async (client) => { diff --git a/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs index 9213288907..8adbe3b46f 100644 --- a/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-sidecar-contract.test.mjs @@ -47,6 +47,44 @@ test("the ACPX sidecar schema accepts each versioned message family", () => { } }); +test("Pi session admission requires an explicit supported thinking level including recovery", () => { + const open = (params) => ({ protocolVersion, id: 1, command: "session.open", params }); + for (const piThinkingLevel of ["off", "low", "high", "max"]) { + assert.equal(validate(open({ agent: "pi", piThinkingLevel })), true, JSON.stringify(validate.errors)); + assert.equal(validate(open({ agent: "pi", piThinkingLevel, expectedIdentity: null })), true); + assert.equal(validate(open({ agent: "pi", piThinkingLevel, expectedIdentity: { piThinkingLevel } })), true); + } + for (const piThinkingLevel of [undefined, null, "medium", "minimal", "xhigh", " LOW ", 1]) { + assert.equal(validate(open({ agent: "pi", piThinkingLevel })), false); + assert.equal(validate(open({ agent: "pi", piThinkingLevel: "low", expectedIdentity: { piThinkingLevel } })), false); + } + for (const agent of ["claude", "codex", "grok", "cursor", "copilot"]) { + assert.equal(validate(open({ agent })), true); + assert.equal(validate(open({ agent, expectedIdentity: null })), true); + assert.equal(validate(open({ agent, piThinkingLevel: "low" })), false); + assert.equal(validate(open({ agent, expectedIdentity: { piThinkingLevel: "low" } })), false); + } +}); + +test("public Pi descriptors preserve effective thinking levels without invalidating historical replay", async () => { + const descriptorSchema = JSON.parse(await readFile(new URL("../protocol/schemas/provider-descriptor.schema.json", import.meta.url), "utf8")); + const validateDescriptor = new Ajv2020({ allErrors: true, strict: true, strictRequired: false }).compile(descriptorSchema); + const historical = { + provider: "acpx", driver: "acpx_runtime", model: "model", executionKind: "local_process", + providerVersion: "0.13.1", agent: "pi", requestedModel: "model", acpProtocolVersion: 1, + agentServerPackage: "pi-acp", agentServerVersion: "0.0.33", acpxRecordId: null, agentProcessId: null, + }; + assert.equal(validateDescriptor(historical), true, JSON.stringify(validateDescriptor.errors)); + for (const piThinkingLevel of ["off", "low", "high", "max"]) { + assert.equal(validateDescriptor({ ...historical, piThinkingLevel }), true); + } + for (const piThinkingLevel of ["medium", null, "xhigh", 1]) { + assert.equal(validateDescriptor({ ...historical, piThinkingLevel }), false); + } + assert.equal(validateDescriptor({ ...historical, agent: "copilot", piThinkingLevel: "low" }), false); + assert.equal(validateDescriptor({ ...historical, provider: "codex", driver: "codex_app_server", piThinkingLevel: "low" }), false); +}); + test("the ACPX sidecar schema shares the durable stable-identity boundary", () => { const longestTurnId = "t".repeat(240); assert.equal(validate({ ...messages[2], turnId: longestTurnId }), true); diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v13-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v13-identity.json new file mode 100644 index 0000000000..3f443926d7 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v13-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:3ff08fbe76fe4549c9eb01e8794428d8909c65c151d775220f2ec111d9e6f7c1", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 13, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "687270d7bbe3dccce6c2fed8664e76cda724b43e545cb153b9f54b018de52927", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "8a43ebafb7a85dd1714ed6945670472666dcb63e8af32b4a74644163fa0e370d", + "toolEvidenceSourceSha256": "4b517eed447615b3cc820af348ccc360978175149caa0bbc838d1f78332abe75", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "560010031c58cf6d492784f62fadc96f33bec9c1d12e67fece48b4dfcee518ed", + "semanticDirectDriverSourceSha256": "2db465e56ba41f06b406e039e566d9e3aea0a014fc6a47a6a54d5bc682dabcab", + "semanticValidationSourceSha256": "039fca52266fe21ba74ca64660d21228314d8ed737b19ba67941bfb25086e4d5", + "semanticNormalizationSourceSha256": "b1ce59251bf9c671f66e0e5173646190b9a2d1bfc5bdb7fb123824213986b92b", + "semanticCompletionContractSourceSha256": "00a684f9aea87f68eabf0974681c52461bc308631a1252a89c4ed087d7158b28", + "semanticValidatorsSourceSha256": "950b49b233d8dd62ff858398becfc55f0a181160cb8a434d9221b5c17816867c", + "semanticSchemaBundleSourceSha256": "2974c75590f816fbdbc7806f531698fdc8d62a99601a17625b81de0f074d6383", + "semanticSidecarProtocolSourceSha256": "72d9800603a652d6580084bb3b5dac4d4dd6d644324b0d15d293d7c198fd6b55", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v14-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v14-identity.json new file mode 100644 index 0000000000..7419126628 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v14-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:5e5955c57917a7e7c25703b3ed9e420cd72105eab033b611508dc37fdba3858b", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 14, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "687270d7bbe3dccce6c2fed8664e76cda724b43e545cb153b9f54b018de52927", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "8a43ebafb7a85dd1714ed6945670472666dcb63e8af32b4a74644163fa0e370d", + "toolEvidenceSourceSha256": "4b517eed447615b3cc820af348ccc360978175149caa0bbc838d1f78332abe75", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "b83898b2453d0f425275d3bc58140675fc8f14505703103079094093f16be43f", + "semanticDirectDriverSourceSha256": "2db465e56ba41f06b406e039e566d9e3aea0a014fc6a47a6a54d5bc682dabcab", + "semanticValidationSourceSha256": "039fca52266fe21ba74ca64660d21228314d8ed737b19ba67941bfb25086e4d5", + "semanticNormalizationSourceSha256": "b1ce59251bf9c671f66e0e5173646190b9a2d1bfc5bdb7fb123824213986b92b", + "semanticCompletionContractSourceSha256": "00a684f9aea87f68eabf0974681c52461bc308631a1252a89c4ed087d7158b28", + "semanticValidatorsSourceSha256": "950b49b233d8dd62ff858398becfc55f0a181160cb8a434d9221b5c17816867c", + "semanticSchemaBundleSourceSha256": "2974c75590f816fbdbc7806f531698fdc8d62a99601a17625b81de0f074d6383", + "semanticSidecarProtocolSourceSha256": "72d9800603a652d6580084bb3b5dac4d4dd6d644324b0d15d293d7c198fd6b55", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v15-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v15-identity.json new file mode 100644 index 0000000000..12ef2b3780 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v15-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:8faf47520f156c62c79ea6ca7d1d90a85ddc9a6621f798bd14e87bc6c643bd04", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 15, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "c0139d0b3af085ec5272a7812c9f89aff0e8618d1d44d9c9e9a59525d1972436", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "687270d7bbe3dccce6c2fed8664e76cda724b43e545cb153b9f54b018de52927", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "8a43ebafb7a85dd1714ed6945670472666dcb63e8af32b4a74644163fa0e370d", + "toolEvidenceSourceSha256": "4b517eed447615b3cc820af348ccc360978175149caa0bbc838d1f78332abe75", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "b83898b2453d0f425275d3bc58140675fc8f14505703103079094093f16be43f", + "semanticDirectDriverSourceSha256": "2db465e56ba41f06b406e039e566d9e3aea0a014fc6a47a6a54d5bc682dabcab", + "semanticValidationSourceSha256": "039fca52266fe21ba74ca64660d21228314d8ed737b19ba67941bfb25086e4d5", + "semanticNormalizationSourceSha256": "b1ce59251bf9c671f66e0e5173646190b9a2d1bfc5bdb7fb123824213986b92b", + "semanticCompletionContractSourceSha256": "00a684f9aea87f68eabf0974681c52461bc308631a1252a89c4ed087d7158b28", + "semanticValidatorsSourceSha256": "950b49b233d8dd62ff858398becfc55f0a181160cb8a434d9221b5c17816867c", + "semanticSchemaBundleSourceSha256": "2974c75590f816fbdbc7806f531698fdc8d62a99601a17625b81de0f074d6383", + "semanticSidecarProtocolSourceSha256": "72d9800603a652d6580084bb3b5dac4d4dd6d644324b0d15d293d7c198fd6b55", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v16-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v16-identity.json new file mode 100644 index 0000000000..710031028a --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v16-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:8591f9a78a16aac4cf558733cd512f09def483fc11c673504bf93be7476d994c", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 16, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee", + "toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "91629734a80f0d32295cbce2c573b32af9e345f45302ff4525c269f66811aa06", + "semanticDirectDriverSourceSha256": "2b7b7eaadee658312816ecdcbcb311ce491521fedf39e2ecb571036c36ff7b8e", + "semanticValidationSourceSha256": "039fca52266fe21ba74ca64660d21228314d8ed737b19ba67941bfb25086e4d5", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "eec0e3740173ea377e73f54da1260a9063606e0dcbc3f08ddb01942a50085f56", + "semanticValidatorsSourceSha256": "950b49b233d8dd62ff858398becfc55f0a181160cb8a434d9221b5c17816867c", + "semanticSchemaBundleSourceSha256": "2974c75590f816fbdbc7806f531698fdc8d62a99601a17625b81de0f074d6383", + "semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1" + } +} diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v11-identity.json b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v11-identity.json new file mode 100644 index 0000000000..1b954a59a1 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v11-identity.json @@ -0,0 +1,59 @@ +{ + "commandDigest": "sha256:13207d7b6afcfe681d4fe9098655df056c2cf87e03f5141e961cd01c32c5bdd2", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "cursor", + "agentProfileVersion": 11, + "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": "c0139d0b3af085ec5272a7812c9f89aff0e8618d1d44d9c9e9a59525d1972436", + "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/registered-asset-instructions.json b/packages/paperclip-runner/test/fixtures/registered-asset-instructions.json index d978802f87..f51a893006 100644 --- a/packages/paperclip-runner/test/fixtures/registered-asset-instructions.json +++ b/packages/paperclip-runner/test/fixtures/registered-asset-instructions.json @@ -19,7 +19,7 @@ } }, "entry": "Custom entry quoting /registered/run-copy stays unchanged.", - "instructions": "Pinned prompt.\n\nCustom entry quoting /registered/run-copy stays unchanged.\n\nPinned connection policy.\n\nYour persistent agent directory (AGENT_HOME) is /registered/run-copy. Your instruction entry is AGENTS.md, relative to that directory. All supported files and subfolders there are restored across tasks and sessions, and collected after this provider stops. Write task deliverables in the task working directory. Only changed or deleted files synchronize; the last sync wins for the same file. Temporary copies are cleaned up without retaining file history. Check the save receipt before claiming persistence.\n\nRead-only instruction sibling root: /registered/bundle" + "instructions": "Pinned prompt.\n\nCustom entry quoting /registered/run-copy stays unchanged.\n\nPinned connection policy.\n\nYour persistent agent directory (AGENT_HOME) is /registered/run-copy. This is the current turn's copy; its absolute path may change between turns. In shell commands, use the current $AGENT_HOME environment variable instead of an absolute agent-directory path from an earlier turn. Your instruction entry is AGENTS.md, relative to that directory. All supported files and subfolders there are restored across tasks and sessions, and collected after this provider stops. Write task deliverables in the task working directory. Only changed or deleted files synchronize; the last sync wins for the same file. Temporary copies are cleaned up without retaining file history. Check the save receipt before claiming persistence.\n\nRead-only instruction sibling root: /registered/bundle" }, { "context": { diff --git a/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs b/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs index 4c52910c77..6d1dce690a 100644 --- a/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs +++ b/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs @@ -149,6 +149,21 @@ test("cancellation closes the iteration before a warm prompt reuses native IDs", assert.equal(new Set(starts.map(update => update.toolCallId)).size, 3); }); +test("acknowledged native cancellation preserves partial usage and refuses service-failure metadata", async t => { + const f = await fixture(t, { PI_FIXTURE_CANCEL_ERROR: "1" }); + const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + const active = f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "long" }] }); + await f.call("pi/steer", { sessionId: session.sessionId, message: "fixture admission fence" }); + f.notify("session/cancel", { sessionId: session.sessionId }); + const result = await active; + assert.equal(result.stopReason, "cancelled"); + assert.equal(result._meta, undefined); + assert.equal(result.usage.inputTokens, 11); + assert.equal(result.usage.outputTokens, 3); + assert.equal(result.usage.cachedReadTokens, undefined); + assert.equal(result.usage._meta.paperclipPi.costUsd, undefined); +}); + test("warm load uses stable display-only history IDs distinct from live execution", async (t) => { const f = await fixture(t, { PI_FIXTURE_HISTORY: "1" }); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); @@ -297,3 +312,63 @@ for (const outcome of ["success", "failure", "oversized"]) { assert.equal(terminal._meta.terminal_exit.exit_code, outcome === "failure" ? 7 : 0); }); } + + +const thoughtOption = configuration => configuration.configOptions.find(option => option.id === "thought_level"); +const assertedThinking = (configuration, current, levels = ["off", "low", "high", "max"]) => { + assert.equal(configuration.modes.currentModeId, current); + assert.deepEqual(configuration.modes.availableModes.map(mode => mode.id), levels); + assert.equal(thoughtOption(configuration).currentValue, current); + assert.deepEqual(thoughtOption(configuration).options.map(option => option.value), levels); +}; + +test("thinking modes use native capabilities and preserve effective max across warm load", async t => { + const f = await fixture(t); + const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + assertedThinking(session, "off"); + for (const level of ["high", "low", "max"]) { + const result = await f.call("session/set_config_option", { sessionId: session.sessionId, configId: "thought_level", value: level }); + assert.equal(thoughtOption(result).currentValue, level); + assert.equal(f.notifications.filter(event => event.params?.update?.sessionUpdate === "current_mode_update").at(-1).params.update.currentModeId, level); + } + assertedThinking(await f.call("session/load", { sessionId: session.sessionId, cwd: join(f.root, "workspace"), mcpServers: [] }), "max"); + await f.call("session/set_mode", { sessionId: session.sessionId, modeId: "off" }); + assert.equal(f.notifications.filter(event => event.params?.update?.sessionUpdate === "current_mode_update").at(-1).params.update.currentModeId, "off"); +}); + +test("unsupported thinking aliases are rejected before native mutation on both ACP routes", async t => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + for (const level of ["minimal", "medium", "xhigh", "unknown"]) { + await assert.rejects(f.call("session/set_mode", { sessionId: session.sessionId, modeId: level }), /Unsupported thinking level/); + await assert.rejects(f.call("session/set_config_option", { sessionId: session.sessionId, configId: "thought_level", value: level }), /Unsupported thinking level/); + } + await assert.rejects(readFile(join(f.root, "agent/thinking-calls.jsonl")), { code: "ENOENT" }); + assertedThinking(await f.call("session/load", { sessionId: session.sessionId, cwd: join(f.root, "workspace"), mcpServers: [] }), "off"); +}); + +test("model changes refresh supported modes and truthful effective state", async t => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + await f.call("session/set_mode", { sessionId: session.sessionId, modeId: "low" }); + const result = await f.call("session/set_config_option", { sessionId: session.sessionId, configId: "model", value: "openrouter/fixture-alternate" }); + assert.equal(thoughtOption(result).currentValue, "high"); + assert.deepEqual(thoughtOption(result).options.map(option => option.value), ["off", "high"]); + await assert.rejects(f.call("session/set_mode", { sessionId: session.sessionId, modeId: "low" }), /Unsupported thinking level/); +}); + +for (const method of ["session/set_mode", "session/set_config_option"]) test(`${method} rejects native silent clamping without a false success update`, async t => { + const f = await fixture(t, { PI_FIXTURE_THINKING_CLAMP: "1" }); + const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + const from = f.notifications.length; + await assert.rejects(f.call(method, { sessionId: session.sessionId, ...(method === "session/set_mode" ? { modeId: "low" } : { configId: "thought_level", value: "low" }) }), /Internal error|did not apply/); + assert.ok(!f.notifications.slice(from).some(event => event.params?.update?.sessionUpdate === "current_mode_update")); + assert.deepEqual((await readFile(join(f.root, "agent/thinking-calls.jsonl"), "utf8")).trim().split("\n").map(JSON.parse), ["low"]); +}); + +for (const capability of ["failed", "duplicate", "unknown", "empty", "missing"]) test(`thinking admission rejects ${capability} native capabilities`, async t => { + const f = await fixture(t, { PI_FIXTURE_THINKING_CAPABILITIES: capability }); + await assert.rejects(f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }), /Internal error/); +}); +for (const current of ["future", "medium"]) test(`thinking admission rejects unsupported effective state ${current}`, async t => { + const f = await fixture(t, { PI_FIXTURE_THINKING_CURRENT: current }); + await assert.rejects(f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }), /Internal error/); +}); diff --git a/packages/paperclip-runner/test/pi-closed-startup.test.mjs b/packages/paperclip-runner/test/pi-closed-startup.test.mjs index 775e6411af..378e166acf 100644 --- a/packages/paperclip-runner/test/pi-closed-startup.test.mjs +++ b/packages/paperclip-runner/test/pi-closed-startup.test.mjs @@ -14,12 +14,12 @@ if (process.argv[2] === "--pi-no-key-probe") { const workspace = join(root, "workspace"); await mkdir(workspace); const evidence = []; const started = performance.now(); const bundle = createCapabilityRunnerdCodexTransport({ - provider: "acpx", acpxAgent: "pi", acpxCandidateProfile: "pi", acpxPermissionMode: "deny-all", + provider: "acpx", acpxAgent: "pi", piThinkingLevel: "low", acpxPermissionMode: "deny-all", runnerBinary: daemon, stateDirectory: join(root, "state"), environment: { PATH: "/usr/bin:/bin", LANG: "en_US.UTF-8" }, onEvidence: value => evidence.push(value), }); - let error = null; let settledMs; + let error = null; let closeError = null; let settledMs; try { await bundle.transport.request("initialize", { clientInfo: { name: "pi-closed-startup-regression", version: "1" } }); await bundle.transport.request("thread/start", { @@ -28,13 +28,17 @@ if (process.argv[2] === "--pi-no-key-probe") { permissions: "paperclip-runner-workspace-read-only", dynamicTools: [], }); } catch (failure) { error = String(failure); } - finally { settledMs = Math.round(performance.now() - started); await bundle.transport.close(); } - await writeFile(join(root, "report.json"), JSON.stringify({ error, settledMs, durationMs: Math.round(performance.now() - started), evidence }), { mode: 0o600 }); + finally { + settledMs = Math.round(performance.now() - started); + try { await bundle.transport.close(); } catch (failure) { closeError = String(failure); } + } + await writeFile(join(root, "report.json"), JSON.stringify({ error, closeError, settledMs, durationMs: Math.round(performance.now() - started), evidence }), { mode: 0o600 }); } else { const packageRoot = process.env.PAPERCLIP_TEST_PI_STARTUP_PACKAGE_ROOT; const daemon = process.env.PAPERCLIP_TEST_PI_STARTUP_RUNNER_BINARY; + // 60 s cold admission + 20 s cleanup watchdog + 10 s test supervision. test("closed Pi Runner startup rejects admission and closes without a prompt", { - skip: !packageRoot || !daemon, timeout: 60_000, + skip: !packageRoot || !daemon, timeout: 90_000, }, async () => { const root = await mkdtemp(join(tmpdir(), "pi-closed-startup-")); await mkdir(join(root, "home")); @@ -43,16 +47,17 @@ if (process.argv[2] === "--pi-no-key-probe") { env: { HOME: join(root, "home"), PATH: "/usr/bin:/bin", LANG: "en_US.UTF-8" }, stdio: ["ignore", "pipe", "pipe"], }); let stderr = ""; child.stdout.resume(); child.stderr.on("data", chunk => { stderr += chunk; }); - const timer = setTimeout(() => child.kill("SIGTERM"), 55_000); + const timer = setTimeout(() => child.kill("SIGTERM"), 80_000); try { const exitCode = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", resolve); }); assert.equal(exitCode, 0, stderr); } finally { clearTimeout(timer); } const report = JSON.parse(await readFile(join(root, "report.json"), "utf8")); + assert.equal(report.closeError, null, report.closeError); assert.match(report.error, /session\.open failed/); assert.match(report.error, /retryable=false, classification=session_ensure_failed/); assert.doesNotMatch(report.error, /timed out/); - assert.ok(report.settledMs < 30_000, `session.open took ${report.settledMs} ms`); + assert.ok(report.settledMs < 60_000, `session.open took ${report.settledMs} ms`); assert.ok(report.evidence.some(item => item.runnerExited === true && item.runnerExitCode === 0)); for (const item of report.evidence) assert.deepEqual(item.childEnvironmentKeys, ["LANG", "PATH"]); const state = JSON.parse(await readFile(join(root, "state/runner/acpx-provider-state.json"), "utf8")); @@ -60,6 +65,10 @@ if (process.argv[2] === "--pi-no-key-probe") { assert.equal(state.activeTurnId, null); assert.equal(state.identity, null); assert.equal(state.providerExitUnconfirmed, false); console.log(JSON.stringify({ settledMs: report.settledMs, durationMs: report.durationMs, credentials: "none", promptCalls: 0 })); - } finally { await rm(root, { recursive: true, force: true }); } + } catch (error) { + console.error(`Pi startup failure evidence retained at ${root}`); + throw error; + } + await rm(root, { recursive: true, force: true }); }); } diff --git a/packages/paperclip-runner/test/pi-native-package-contract.test.mjs b/packages/paperclip-runner/test/pi-native-package-contract.test.mjs index d85f49984d..fa74a09b6f 100644 --- a/packages/paperclip-runner/test/pi-native-package-contract.test.mjs +++ b/packages/paperclip-runner/test/pi-native-package-contract.test.mjs @@ -359,37 +359,91 @@ test("real pinned Pi tool dispatch admits registered agent files and rejects una // This regression crosses Pi 1's real RPC serializer, the owned extension and // the patched ACP wrapper. Only an owned loopback HTTP fixture can be reached; // dummy authentication is unrelated to any provider account. -for (const scenario of ["hello", "interleaved-tools", "provider-error", "provider-unknown"]) { +for (const scenario of ["hello", "interleaved-tools", "provider-error", "provider-unknown", "thinking-modes", "warm-recovery", "warm-pending-cancel"]) { test(`real Pi 1 serialized RPC ${scenario} through owned ACP/MCP bridge`, { timeout: 30_000 }, async t => { const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-stream-"))); let child; let server; + const warm = scenario.startsWith("warm-"); + const retirements = []; t.after(async () => { if (child) { - child.stdin.end(); + // Loaded-session command updates can still be pending at EOF. Retire + // through the owned wrapper handler so every native child is awaited. + if (scenario === "thinking-modes" || warm) child.kill("SIGTERM"); else child.stdin.end(); if (child.exitCode === null) { const timer = setTimeout(() => child.kill("SIGTERM"), 3000); await once(child, "exit"); clearTimeout(timer); } } if (server?.listening) { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); } try { - if (child) assert.equal(JSON.parse(await readFile(join(root, "owned-retirement.json"), "utf8")).allOwnedChildrenClosed, true); + if (child) { + const retirement = JSON.parse(await readFile(join(root, "owned-retirement.json"), "utf8")); + retirements.push(retirement); + assert.ok(retirements.every(value => value.allOwnedChildrenClosed && value.spawnErrors === 0)); + if (warm) assert.deepEqual(retirements.map(value => value.count), [1, 1]); + if (scenario === "thinking-modes") { assert.equal(retirement.count, 2); assert.equal(retirement.spawnErrors, 0); } + } } finally { await rm(root, { recursive: true, force: true }); } }); await mkdir(join(root, "workspace")); await mkdir(join(root, "agent")); const wrapperRoot = process.env.PAPERCLIP_TEST_PI_ACP_PACKAGE ? dirname(process.env.PAPERCLIP_TEST_PI_ACP_PACKAGE) : join(dirname(dirname(packageRoot)), "pi-acp"); - const calls = []; const modelRequests = []; + const calls = []; const modelRequests = []; const reasoningRequests = []; + const agentHomes = [join(root, "run-1-agent"), join(root, "run-2-agent")]; + if (warm) for (const path of agentHomes) await mkdir(path); + const contracts = [ + { revision: "1", criterionIds: ["objective"] }, + { revision: "2", criterionIds: ["human_response"] }, + ]; + const finishArguments = index => ({ + reportedWorkDisposition: "needs_review", summary: `WARM_T${index + 1}`, + completionClaim: { contractRevision: contracts[index].revision, objectiveSatisfied: true, + criteria: [{ criterionId: contracts[index].criterionIds[0], status: "satisfied", evidenceRefs: [] }], remainingWork: [] }, + attentionRequests: [{ kind: "review", ownerClass: "human", summary: "Review fixture result" }], + evidence: [], verification: [], + }); + let stalledResponse; const tools = ["paperclip_get_context", "paperclip_finish"].map(name => ({ name, description: name, inputSchema: { type: "object", properties: {}, additionalProperties: true } })); server = createServer(async (request, response) => { let body = ""; for await (const chunk of request) { body += chunk; assert.ok(body.length < 1_048_576); } const value = JSON.parse(body); if (request.url === "/v1/chat/completions") { - modelRequests.push(value.model); assert.ok(modelRequests.length <= 3); + modelRequests.push(value.model); reasoningRequests.push({ model: value.model, reasoning: value.reasoning }); assert.ok(modelRequests.length <= (warm ? 6 : 3)); if (scenario.startsWith("provider-")) { response.writeHead(scenario === "provider-error" ? 401 : 418, { "content-type": "application/json" }); response.end(JSON.stringify({ error: { message: "Bearer sensitive-canary /private/sensitive-canary", type: "authentication_error" } })); return; } const n = modelRequests.length; - const toolTurn = n === 1 || scenario === "hello" && n === 2; + if (warm) { + const index = n <= 3 ? 0 : 1; + const step = (n - 1) % 3; + const latestUser = value.messages.filter(message => message.role === "user").at(-1); + const submitted = JSON.parse(typeof latestUser.content === "string" ? latestUser.content + : latestUser.content.filter(block => block.type === "text").map(block => block.text).join("")); + assert.deepEqual(submitted.completion, contracts[index]); + assert.equal(submitted.events[0].agentHome, agentHomes[index]); + const tool = step === 0 + ? { name: index === 0 ? "write" : "read", arguments: index === 0 + ? { path: join(agentHomes[index], "memory.txt"), content: "T1 fixture memory\n" } + : { path: join(agentHomes[index], "memory.txt") } } + : { name: "mcp__paperclip__paperclip_finish", arguments: finishArguments(index) }; + const chunks = []; + if (step < 2) { + const args = JSON.stringify(tool.arguments); + chunks.push({ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: `warm_${index}_${step}`, type: "function", function: { name: tool.name, arguments: args.slice(0, 12) } }] } }] }); + chunks.push({ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: args.slice(12) } }] } }] }); + } else chunks.push({ choices: [{ index: 0, delta: { content: `WARM_T${index + 1}` } }] }); + response.writeHead(200, { "content-type": "text/event-stream" }); + response.write(chunks.map(chunk => `data: ${JSON.stringify({ id: "offline-warm", ...chunk })}\n\n`).join("")); + if (scenario === "warm-pending-cancel" && n === 5) { + // A complete JSON argument buffer is still only generation. Withhold + // the provider finish marker: the real SDK must not execute it. + stalledResponse = response; + return; + } + response.end(`data: ${JSON.stringify({ id: "offline-warm", choices: [{ index: 0, delta: {}, finish_reason: step < 2 ? "tool_calls" : "stop" }], usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 } })}\n\ndata: [DONE]\n\n`); + return; + } + const toolTurn = scenario !== "thinking-modes" && (n === 1 || scenario === "hello" && n === 2); const names = scenario === "interleaved-tools" ? tools.map(tool => tool.name) : [n === 1 ? tools[0].name : tools[1].name]; const chunks = []; if (toolTurn) { @@ -403,6 +457,11 @@ for (const scenario of ["hello", "interleaved-tools", "provider-error", "provide } assert.equal(request.url, "/mcp"); calls.push({ method: value.method, name: value.params?.name, args: value.params?.arguments }); + if (warm && value.method === "tools/call") { + assert.equal(value.params.name, "paperclip_finish"); + const index = calls.filter(call => call.method === "tools/call").length - 1; + assert.deepEqual(value.params.arguments, finishArguments(index)); + } const result = value.method === "initialize" ? { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } } : value.method === "tools/list" ? { tools } @@ -461,27 +520,40 @@ process.exit = code => { process.on("SIGTERM", () => process.exit(143)); process.on("SIGINT", () => process.exit(130)); await import(${JSON.stringify(pathToFileURL(join(wrapperRoot, "dist/index.js")).href)}); `); - child = spawn(process.execPath, [owner], { - cwd: join(root, "workspace"), stdio: "pipe", env: { - PATH: "/usr/bin:/bin", HOME: root, PI_CODING_AGENT_DIR: join(root, "agent"), PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0", - PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", PAPERCLIP_PI_READ_ONLY: "0", - PAPERCLIP_PI_NODE_EXECUTABLE: await realpath(process.execPath), PAPERCLIP_PI_ENTRYPOINT: bootstrap, PAPERCLIP_PI_EXTENSION_PATH: extension, - }, - }); - let buffered = ""; let stderr = ""; let sequence = 0; const pending = new Map(); const notifications = []; - child.stderr.on("data", chunk => { stderr += chunk; }); - const decoder = new StringDecoder("utf8"); - child.stdout.on("data", chunk => { - buffered += decoder.write(chunk); - for (;;) { - const index = buffered.indexOf("\n"); if (index < 0) break; - const message = JSON.parse(buffered.slice(0, index)); buffered = buffered.slice(index + 1); - const waiter = pending.get(message.id); - if (waiter) { pending.delete(message.id); message.error ? waiter.reject(new Error(JSON.stringify(message.error))) : waiter.resolve(message.result); } - else notifications.push(message); - } - }); - child.once("exit", () => { for (const waiter of pending.values()) waiter.reject(new Error(`wrapper exited: ${stderr}`)); pending.clear(); }); + const launchWrapper = async agentHome => { + child = spawn(process.execPath, [owner], { + cwd: join(root, "workspace"), stdio: "pipe", env: { + PATH: "/usr/bin:/bin", HOME: root, PI_CODING_AGENT_DIR: join(root, "agent"), PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0", + PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", PAPERCLIP_PI_READ_ONLY: "0", + ...(agentHome ? { PAPERCLIP_PI_AGENT_HOME: agentHome } : {}), + PAPERCLIP_PI_NODE_EXECUTABLE: await realpath(process.execPath), PAPERCLIP_PI_ENTRYPOINT: bootstrap, PAPERCLIP_PI_EXTENSION_PATH: extension, + }, + }); + let buffered = ""; + child.stderr.on("data", chunk => { stderr += chunk; }); + const decoder = new StringDecoder("utf8"); + child.stdout.on("data", chunk => { + buffered += decoder.write(chunk); + for (;;) { + const index = buffered.indexOf("\n"); if (index < 0) break; + const message = JSON.parse(buffered.slice(0, index)); buffered = buffered.slice(index + 1); + if (warm && message.method === "session/request_permission") { + const option = message.params.options.find(value => value.kind === "allow_once"); + assert.ok(option); + const path = message.params.toolCall.rawInput?.path; + assert.equal(path, join(agentHome, "memory.txt")); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { outcome: { outcome: "selected", optionId: option.optionId } } }) + "\n"); + continue; + } + const waiter = pending.get(message.id); + if (waiter) { pending.delete(message.id); message.error ? waiter.reject(new Error(JSON.stringify(message.error))) : waiter.resolve(message.result); } + else notifications.push(message); + } + }); + child.once("exit", () => { for (const waiter of pending.values()) waiter.reject(new Error(`wrapper exited: ${stderr}`)); pending.clear(); }); + }; + let stderr = ""; let sequence = 0; const pending = new Map(); const notifications = []; + await launchWrapper(warm ? agentHomes[0] : undefined); const call = (method, params) => new Promise((resolve, reject) => { const id = ++sequence; const timer = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, 15_000); pending.set(id, { resolve(value) { clearTimeout(timer); resolve(value); }, reject(error) { clearTimeout(timer); reject(error); } }); @@ -490,7 +562,94 @@ await import(${JSON.stringify(pathToFileURL(join(wrapperRoot, "dist/index.js")). await call("initialize", { protocolVersion: 1, clientCapabilities: {} }); const session = await call("session/new", { cwd: join(root, "workspace"), mcpServers: [{ type: "http", name: "paperclip", url: `http://127.0.0.1:${port}/mcp`, headers: [{ name: "Authorization", value: "Bearer 0123456789abcdef0123456789abcdef" }] }] }); const model = "openrouter/deepseek/deepseek-v4-flash-0731"; - await call("session/set_config_option", { sessionId: session.sessionId, configId: "model", value: model }); + const selected = await call("session/set_config_option", { sessionId: session.sessionId, configId: "model", value: model }); + if (warm) { + const low = await call("session/set_config_option", { sessionId: session.sessionId, configId: "thought_level", value: "low" }); + assert.equal(low.configOptions.find(option => option.id === "thought_level").currentValue, "low"); + const prompt = index => [{ type: "text", text: JSON.stringify({ schema: "paperclip.native-continuation.v1", completion: contracts[index], events: [{ type: "fixture", agentHome: agentHomes[index] }] }) }]; + assert.equal((await call("session/prompt", { sessionId: session.sessionId, prompt: prompt(0) })).stopReason, "end_turn"); + assert.equal(await readFile(join(agentHomes[0], "memory.txt"), "utf8"), "T1 fixture memory\n"); + const firstUpdates = notifications.map(event => event.params?.update).filter(Boolean); + const firstFinish = firstUpdates.find(update => update.sessionUpdate === "tool_call" && update.title === "mcp__paperclip__paperclip_finish"); + assert.ok(firstFinish); + assert.equal(firstUpdates.filter(update => update.toolCallId === firstFinish.toolCallId && update.status === "completed").length, 1); + // Product ends the old provider for registered-file collection. Reopen + // the saved native session in a fresh wrapper, with the next admitted + // AGENT_HOME; the file transfer here models that external sync boundary. + const firstClosed = once(child, "close"); child.kill("SIGTERM"); await firstClosed; + retirements.push(JSON.parse(await readFile(join(root, "owned-retirement.json"), "utf8"))); + await writeFile(join(agentHomes[1], "memory.txt"), await readFile(join(agentHomes[0], "memory.txt"))); + await rm(agentHomes[0], { recursive: true }); + await launchWrapper(agentHomes[1]); + await call("initialize", { protocolVersion: 1, clientCapabilities: {} }); + const loaded = await call("session/load", { sessionId: session.sessionId, cwd: join(root, "workspace"), mcpServers: [{ type: "http", name: "paperclip", url: `http://127.0.0.1:${port}/mcp`, headers: [{ name: "Authorization", value: "Bearer 0123456789abcdef0123456789abcdef" }] }] }); + assert.equal(loaded.modes.currentModeId, "low"); + const secondStart = notifications.length; + const second = call("session/prompt", { sessionId: session.sessionId, prompt: prompt(1) }); + second.catch(() => {}); // Cleanup still drains a failed/pending prompt if an assertion throws. + if (scenario === "warm-pending-cancel") { + await new Promise((resolve, reject) => { + const until = Date.now() + 5000; + const check = () => { + const updates = notifications.slice(secondStart).map(event => event.params?.update).filter(Boolean); + const finish = updates.find(update => update.sessionUpdate === "tool_call" && update.title === "mcp__paperclip__paperclip_finish"); + if (stalledResponse && finish && updates.some(update => update.toolCallId === finish.toolCallId && JSON.stringify(update.rawInput) === JSON.stringify(finishArguments(1)))) return resolve(); + if (Date.now() >= until) return reject(new Error("pending-only fixture boundary not observed")); + setTimeout(check, 10); + }; check(); + }); + assert.equal(calls.filter(call => call.method === "tools/call").length, 1, "pending generation cannot invoke the MCP bridge"); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId: session.sessionId } }) + "\n"); + assert.equal((await second).stopReason, "cancelled"); + } else assert.equal((await second).stopReason, "end_turn"); + const updates = notifications.slice(secondStart).map(event => event.params?.update).filter(Boolean); + assert.ok(modelRequests.every(value => value === "deepseek/deepseek-v4-flash-0731")); + const readIds = [...new Set(updates.filter(update => update.rawInput?.path === join(agentHomes[1], "memory.txt")).map(update => update.toolCallId))]; + assert.equal(readIds.length, 1); + const readLifecycle = updates.filter(update => update.toolCallId === readIds[0]); + assert.ok(readLifecycle.some(update => update.sessionUpdate === "tool_call" && update.title === "read")); + assert.equal(readLifecycle.filter(update => update.status === "failed").length, 0); + const readCompleted = readLifecycle.filter(update => update.status === "completed"); + assert.equal(readCompleted.length, 1); + assert.ok(readCompleted[0].content.some(block => block.type === "content" && block.content?.type === "text" && block.content.text.includes("T1 fixture memory"))); + const secondFinish = updates.find(update => update.sessionUpdate === "tool_call" && update.title === "mcp__paperclip__paperclip_finish"); + assert.ok(secondFinish); assert.notEqual(secondFinish.toolCallId, firstFinish.toolCallId); + const lifecycle = updates.filter(update => update.toolCallId === secondFinish.toolCallId); + const executed = scenario === "warm-recovery"; + assert.equal(lifecycle.some(update => update.status === "in_progress"), executed); + assert.equal(lifecycle.filter(update => update.status === "completed").length, executed ? 1 : 0); + assert.equal(calls.filter(call => call.method === "tools/call").length, executed ? 2 : 1); + assert.equal(modelRequests.length, executed ? 6 : 5); + assert.ok(reasoningRequests.every(request => request.reasoning?.effort === "low")); + assert.deepEqual(JSON.parse(await readFile(join(root, "network-denial.json"), "utf8")), { deniedBeforeConnect: true, underlyingConnections: 0 }); + t.diagnostic(JSON.stringify({ scenario, prompts: 2, modelRequests: modelRequests.length, semanticCalls: executed ? 2 : 1, secondFinishStatuses: lifecycle.map(update => update.status), requestedAndEffectiveMode: "low", restoredSession: true, refreshedAgentHome: true, externalConnections: 0 })); + return; + } + if (scenario === "thinking-modes") { + const thought = config => config.configOptions.find(option => option.id === "thought_level"); + assert.equal(thought(selected).currentValue, "high", "native medium default clamps to high for this exact model"); + assert.deepEqual(thought(selected).options.map(option => option.value), ["off", "low", "high", "max"]); + await call("session/set_mode", { sessionId: session.sessionId, modeId: "high" }); + assert.equal((await call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "Reply HELLO_COMPLETE." }] })).stopReason, "end_turn"); + const low = await call("session/set_config_option", { sessionId: session.sessionId, configId: "thought_level", value: "low" }); + assert.equal(thought(low).currentValue, "low"); + assert.equal((await call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "Reply HELLO_COMPLETE again." }] })).stopReason, "end_turn"); + assert.deepEqual(reasoningRequests, [ + { model: "deepseek/deepseek-v4-flash-0731", reasoning: { effort: "high" } }, + { model: "deepseek/deepseek-v4-flash-0731", reasoning: { effort: "low" } }, + ]); + await assert.rejects(call("session/set_mode", { sessionId: session.sessionId, modeId: "medium" }), /Unsupported thinking level/); + const maximum = await call("session/set_config_option", { sessionId: session.sessionId, configId: "thought_level", value: "max" }); + assert.equal(thought(maximum).currentValue, "max"); + const loaded = await call("session/load", { sessionId: session.sessionId, cwd: join(root, "workspace"), mcpServers: [] }); + assert.equal(loaded.modes.currentModeId, "max"); assert.equal(thought(loaded).currentValue, "max"); + assert.deepEqual(loaded.modes.availableModes.map(mode => mode.id), ["off", "low", "high", "max"]); + const modes = notifications.filter(event => event.params?.update?.sessionUpdate === "current_mode_update").map(event => event.params.update.currentModeId); + assert.deepEqual(modes, ["high", "low", "max"]); + assert.deepEqual(JSON.parse(await readFile(join(root, "network-denial.json"), "utf8")), { deniedBeforeConnect: true, underlyingConnections: 0 }); + assert.equal(calls.filter(call => call.method === "tools/call").length, 0); + return; + } const result = await call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "Call paperclip_get_context, then paperclip_finish. Finally say HELLO_COMPLETE." }] }); assert.deepEqual(JSON.parse(await readFile(join(root, "network-denial.json"), "utf8")), { deniedBeforeConnect: true, underlyingConnections: 0 }); assert.ok(modelRequests.every(value => value === "deepseek/deepseek-v4-flash-0731")); diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 74c740f5ec..ab71435911 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -161,6 +161,17 @@ describe("issue validators", () => { expect(parsed.comment).toBe("Done\n\n- Verified the route"); }); + it("preserves literal escapes in multiline issue descriptions and comments", () => { + const content = `${"0123456789abcdef".repeat(2)}\n`; + const description = ["Write the exact JSON content.", "```json", JSON.stringify({ content }), "```"].join("\n"); + expect(createIssueSchema.parse({ title: "Native memory", description }).description).toBe(description); + expect(updateIssueSchema.parse({ description, comment: description }).description).toBe(description); + expect(updateIssueSchema.parse({ comment: description }).comment).toBe(description); + const storedJson = createIssueSchema.parse({ title: "Native memory", description }).description!.split("\n")[2]!; + expect(JSON.parse(storedJson).content).toBe(content); + expect(Buffer.byteLength(JSON.parse(storedJson).content, "utf8")).toBe(33); + }); + it("validates structured unblock descriptors", () => { expect( updateIssueSchema.parse({ diff --git a/packages/shared/src/validators/text.ts b/packages/shared/src/validators/text.ts index 322597a900..99c753fd63 100644 --- a/packages/shared/src/validators/text.ts +++ b/packages/shared/src/validators/text.ts @@ -1,6 +1,10 @@ import { z } from "zod"; export function normalizeEscapedLineBreaks(value: string): string { + // Recover legacy single-line payloads that encoded their own line breaks. + // A real multiline body already has decoded transport newlines; decoding + // it again corrupts literal escapes in JSON, code, paths and agent prompts. + if (/[\r\n]/.test(value)) return value; return value .replace(/\\r\\n/g, "\n") .replace(/\\n/g, "\n") diff --git a/patches/acpx@0.13.1.patch b/patches/acpx@0.13.1.patch index bf1bb7e9f0..bfc33c0eb2 100644 --- a/patches/acpx@0.13.1.patch +++ b/patches/acpx@0.13.1.patch @@ -561,7 +561,21 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js })); result = claudeAcp ? await withTimeout(createPromise, resolveClaudeAcpSessionCreateTimeoutMs()) : await createPromise; } catch (error) { -@@ -4593,12 +4812,7 @@ +@@ -4589,6 +4808,13 @@ + } catch (error) { + throw maybeWrapSessionControlError("session/set_config_option", error, `for "${configId}"="${value}"`); + } ++ } ++ async requestExtension(method, params) { ++ closedExtensionMethods([method]); ++ const payload = boundedExtensionRecord(params); ++ const connection = this.getConnection(); ++ const result = await this.runConnectionRequest(() => connection.extMethod(method, payload)); ++ return boundedExtensionRecord(result); + } + async setSessionModel(sessionId, modelId, controlOverride) { + const control = this.resolveModelControl(sessionId, controlOverride); +@@ -4593,12 +4819,7 @@ async setSessionModel(sessionId, modelId, controlOverride) { const control = this.resolveModelControl(sessionId, controlOverride); if (!control) throw new RequestedModelUnsupportedError(`Cannot set model "${modelId}": the ACP session did not advertise a model config option or legacy session/set_model support.`, "missing-capability"); @@ -575,7 +589,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js } async setSessionModelThroughConfig(sessionId, modelId, configId) { const connection = this.getConnection(); -@@ -4608,7 +4822,9 @@ +@@ -4608,7 +4829,9 @@ configId, value: modelId })); @@ -586,7 +600,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js return response; } catch (error) { return this.throwSessionModelError("session/set_config_option", modelId, error); -@@ -4864,7 +5080,7 @@ +@@ -4864,7 +5087,7 @@ } selectAuthMethod(methods) { for (const method of methods) { @@ -595,7 +609,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js if (envCredential) return { methodId: method.id, credential: envCredential, -@@ -4890,7 +5106,7 @@ +@@ -4890,7 +5113,7 @@ } readAgentSpecificEnvCredential(methodId) { if (!this.isGrokBuildAcpCommand() || methodId !== "xai.api_key") return; @@ -604,7 +618,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js return typeof value === "string" && value.trim().length > 0 ? value : void 0; } selectAgentManagedAuthMethod(methodId) { -@@ -4915,9 +5131,9 @@ +@@ -4915,9 +5138,9 @@ await connection.authenticate({ methodId: selected.methodId }); this.log(`authenticated with method ${selected.methodId} (${selected.source})`); } @@ -616,7 +630,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js if (hostResponse) return hostResponse; const { response, recorded } = await this.resolvePermissionRequestFromMode(params); if (!recorded) { -@@ -4926,14 +5142,81 @@ +@@ -4926,14 +5149,81 @@ } return response; } @@ -700,7 +714,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js })).then((response) => ({ kind: "response", response -@@ -4970,7 +5253,7 @@ +@@ -4970,7 +5260,7 @@ isElicitationSessionCancelling(sessionId) { return this.closing || this.cancellingSessionIds.has(sessionId); } @@ -709,7 +723,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js if (!this.options.onPermissionRequest) return; const signal = this.cancellationSignalForSession(params.sessionId); try { -@@ -4978,7 +5261,7 @@ +@@ -4978,7 +5268,7 @@ sessionId: params.sessionId, raw: params, inferredKind: inferToolKind(params) @@ -718,7 +732,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js return this.hostPermissionDecisionResponse(params, signal, decision); } catch (error) { return this.hostPermissionErrorResponse(params, signal, error); -@@ -5028,6 +5311,12 @@ +@@ -5028,6 +5318,12 @@ attachAgentLifecycleObservers(child) { child.once("exit", (exitCode, signal) => { this.recordAgentExit("process_exit", exitCode, signal); @@ -731,7 +745,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js }); child.once("close", (exitCode, signal) => { this.recordAgentExit("process_close", exitCode, signal); -@@ -5100,6 +5389,9 @@ +@@ -5100,6 +5396,9 @@ return await this.filesystem.readTextFile(params); } catch (error) { this.recordPermissionError(params.sessionId, error); @@ -741,7 +755,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js throw error; } } -@@ -5239,6 +5531,7 @@ +@@ -5239,6 +5538,7 @@ record.cumulative_token_usage = conversation.cumulative_token_usage; record.cumulative_cost = conversation.cumulative_cost; record.request_token_usage = conversation.request_token_usage; @@ -749,7 +763,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js } //#endregion //#region src/runtime/engine/session-options.ts -@@ -5701,7 +5994,8 @@ +@@ -5701,7 +6001,8 @@ updated_at: conversation.updated_at, cumulative_token_usage: deepClone(conversation.cumulative_token_usage ?? {}), cumulative_cost: cloneUsageCost(conversation.cumulative_cost), @@ -759,7 +773,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js }; } function cloneUsageCost(cost) { -@@ -5771,10 +6065,20 @@ +@@ -5771,10 +6072,20 @@ trimConversationForRuntime(conversation); return acpx; } @@ -782,7 +796,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js updateConversationTimestamp(conversation, timestamp); trimConversationForRuntime(conversation); return true; -@@ -6136,6 +6440,7 @@ +@@ -6136,6 +6447,7 @@ pendingAgentSessionId = loadState.pendingAgentSessionId; sessionModels = loadState.sessionModels; const preferenceReplay = await replayFreshSessionPreferences({ @@ -790,7 +804,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js client, record, createdFreshSession, -@@ -6193,14 +6498,16 @@ +@@ -6193,14 +6505,16 @@ if (shouldReconnect) process.stderr.write(`[acpx] saved session pid ${record.pid} is dead; respawning agent and attempting session reconnect\n`); } async function replayFreshSessionPreferences(params) { @@ -809,7 +823,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js client: params.client, sessionId: params.sessionId, desiredModeId: params.desiredModeId, -@@ -6219,7 +6526,7 @@ +@@ -6219,7 +6533,7 @@ verbose: params.verbose, suppressWarnings: params.suppressWarnings }); @@ -818,7 +832,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js client: params.client, record: params.record, sessionId: params.sessionId, -@@ -6435,6 +6742,27 @@ +@@ -6435,6 +6749,27 @@ //#region src/runtime/engine/prompt-turn.ts const SESSION_REPLY_IDLE_MS = 1e3; const SESSION_REPLY_DRAIN_TIMEOUT_MS = 5e3; @@ -846,7 +860,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js async function runPromptTurn(params) { try { const promptPromise = params.client.prompt(params.sessionId, params.prompt, params.onPromptRequestStarted, params.onElicitation); -@@ -6444,7 +6772,23 @@ +@@ -6444,7 +6779,23 @@ idleMs: SESSION_REPLY_IDLE_MS, timeoutMs: SESSION_REPLY_DRAIN_TIMEOUT_MS }).catch(() => {}); diff --git a/patches/pi-acp@0.0.33.patch b/patches/pi-acp@0.0.33.patch index 4ee3c3c76f..074863bf50 100644 --- a/patches/pi-acp@0.0.33.patch +++ b/patches/pi-acp@0.0.33.patch @@ -142,7 +142,19 @@ } } /** -@@ -242,7 +245,7 @@ +@@ -229,6 +232,11 @@ + if (!res.success) throw new Error(`pi set_model failed: ${res.error ?? JSON.stringify(res.data)}`); + return res.data; + } ++ async getAvailableThinkingLevels() { ++ const res = await this.request({ type: "get_available_thinking_levels" }); ++ if (!res.success) throw new Error(`pi get_available_thinking_levels failed: ${res.error ?? JSON.stringify(res.data)}`); ++ return res.data; ++ } + async setThinkingLevel(level) { + const res = await this.request({ type: "set_thinking_level", level }); + if (!res.success) throw new Error(`pi set_thinking_level failed: ${res.error ?? JSON.stringify(res.data)}`); +@@ -242,7 +250,7 @@ if (!res.success) throw new Error(`pi set_steering_mode failed: ${res.error ?? JSON.stringify(res.data)}`); } async compact(customInstructions) { @@ -151,7 +163,7 @@ if (!res.success) throw new Error(`pi compact failed: ${res.error ?? JSON.stringify(res.data)}`); return res.data; } -@@ -283,17 +286,23 @@ +@@ -283,17 +291,23 @@ await this.writeLine(`${JSON.stringify({ type: "extension_ui_response", ...response })} `); } @@ -181,7 +193,7 @@ }); } writeLine(line) { -@@ -337,7 +346,7 @@ +@@ -337,7 +351,7 @@ { authMethods: getAuthMethods() }, @@ -190,7 +202,7 @@ ); } -@@ -534,6 +543,14 @@ +@@ -534,6 +548,14 @@ const record = value; const command = record?.command ?? record?.cmd ?? record?.args?.command ?? record?.args?.cmd ?? record?.input?.command ?? record?.input?.cmd ?? record?.rawInput?.command ?? record?.rawInput?.cmd ?? record?.toolInput?.command ?? record?.toolInput?.cmd ?? record?.details?.command ?? record?.details?.cmd; return typeof command === "string" && command.trim() ? command : void 0; @@ -205,7 +217,7 @@ } function bashResultText(result) { const record = result; -@@ -714,6 +731,7 @@ +@@ -714,6 +736,7 @@ try { proc = await PiRpcProcess.spawn({ cwd: params.cwd, @@ -213,7 +225,7 @@ piCommand: params.piCommand }); } catch (e) { -@@ -808,6 +826,9 @@ +@@ -808,6 +831,9 @@ this.proc = opts.proc; this.conn = opts.conn; this.fileCommands = opts.fileCommands ?? []; @@ -223,7 +235,7 @@ this.proc.onEvent((ev) => this.handlePiEvent(ev)); } setStartupInfo(text) { -@@ -822,10 +843,7 @@ +@@ -822,10 +848,7 @@ sendStartupInfoIfPending() { if (this.startupInfoSent || !this.startupInfo) return; this.startupInfoSent = true; @@ -235,7 +247,7 @@ } async prompt(message, images = []) { const expandedMessage = expandSlashCommand(message, this.fileCommands); -@@ -852,6 +870,7 @@ +@@ -852,6 +875,7 @@ } async cancel() { this.cancelRequested = true; @@ -243,7 +255,7 @@ if (this.turnQueue.length) { const queued = this.turnQueue.splice(0, this.turnQueue.length); for (const t of queued) t.resolve("cancelled"); -@@ -870,6 +889,8 @@ +@@ -870,6 +894,8 @@ return this.cancelRequested; } emit(update) { @@ -252,7 +264,7 @@ this.lastEmit = this.lastEmit.then( () => this.conn.sessionUpdate({ sessionId: this.sessionId, -@@ -877,6 +898,12 @@ +@@ -877,6 +903,12 @@ }) ).catch(() => { }); @@ -265,7 +277,7 @@ } async flushEmits() { await this.lastEmit; -@@ -890,6 +917,7 @@ +@@ -890,6 +922,7 @@ kind: "execute", status: params.status, locations: params.locations, @@ -273,7 +285,7 @@ ...params.includeTerminal ? { content: bashTerminalContent(params.toolCallId) } : {}, ...params.includeTerminal ? { _meta: bashTerminalInfoMeta(params.toolCallId, this.cwd) } : {} }); -@@ -903,6 +931,7 @@ +@@ -903,6 +936,7 @@ sessionUpdate: "tool_call_update", toolCallId: params.toolCallId, status: params.status, @@ -281,7 +293,7 @@ _meta: { ...delta ? bashTerminalOutputMeta(params.toolCallId, delta) : {}, ...params.status === "completed" || params.status === "failed" ? bashTerminalExitMeta(params.toolCallId, bashExitCode(params.result, Boolean(params.isError))) : {} -@@ -920,18 +949,24 @@ +@@ -920,18 +954,24 @@ this.cancelRequested = false; this.inAgentLoop = false; this.pendingTurn = { resolve: t.resolve, reject: t.reject }; @@ -308,7 +320,7 @@ } this.pendingTurn = null; this.inAgentLoop = false; -@@ -946,20 +981,47 @@ +@@ -946,20 +986,47 @@ handlePiEvent(ev) { const type = String(ev.type ?? ""); switch (type) { @@ -364,7 +376,7 @@ break; } if (ame?.type === "toolcall_start" || ame?.type === "toolcall_delta" || ame?.type === "toolcall_end") { -@@ -1047,7 +1109,7 @@ +@@ -1047,7 +1114,7 @@ if (p) { try { const abs = isAbsolute(p) ? p : resolvePath(this.cwd, p); @@ -373,7 +385,7 @@ this.fileSnapshots.set(toolCallId, { path: p, oldText: snapshotOldText }); if (toolName === "edit") { for (const needle of getEditOldTexts(args)) { -@@ -1125,7 +1187,7 @@ +@@ -1125,7 +1192,7 @@ if (!isError && snapshot) { try { const abs = isAbsolute(snapshot.path) ? snapshot.path : resolvePath(this.cwd, snapshot.path); @@ -382,7 +394,7 @@ if (snapshot.oldText === null || newText !== snapshot.oldText) { hasStructuredDiff = true; content = [ -@@ -1154,48 +1216,41 @@ +@@ -1154,48 +1221,41 @@ break; } case "extension_ui_request": { @@ -455,7 +467,7 @@ break; } case "agent_start": { -@@ -1210,9 +1265,12 @@ +@@ -1210,9 +1270,12 @@ break; } case "agent_settled": { @@ -465,11 +477,11 @@ + this.uiBridge.cancelAll(); const reason = this.cancelRequested ? "cancelled" : "end_turn"; - this.pendingTurn?.resolve(reason); -+ this.pendingTurn?.resolve({ stopReason: reason, ...this.turnUsage.response() }); ++ this.pendingTurn?.resolve({ stopReason: reason, ...this.turnUsage.response(reason) }); this.pendingTurn = null; this.inAgentLoop = false; const next = this.turnQueue.shift(); -@@ -1725,6 +1783,7 @@ +@@ -1725,6 +1788,7 @@ return process.env.PI_CODING_AGENT_DIR ? resolve3(process.env.PI_CODING_AGENT_DIR) : join4(homedir4(), ".pi", "agent"); } function getEnableSkillCommands(cwd) { @@ -477,7 +489,7 @@ const merged = getMergedSettings(cwd); const direct = merged.enableSkillCommands; if (typeof direct === "boolean") return direct; -@@ -1733,6 +1792,7 @@ +@@ -1733,6 +1797,7 @@ return true; } function getQuietStartup(cwd) { @@ -485,7 +497,7 @@ const merged = getMergedSettings(cwd); const direct = merged.quietStartup; if (typeof direct === "boolean") return direct; -@@ -1824,6 +1884,7 @@ +@@ -1824,6 +1889,7 @@ const out = []; const seen = /* @__PURE__ */ new Set(); for (const c of [...a, ...b]) { @@ -493,7 +505,7 @@ if (seen.has(c.name)) continue; seen.add(c.name); out.push(c); -@@ -1884,11 +1945,13 @@ +@@ -1884,11 +1950,13 @@ throw RequestError3.invalidParams(`Unknown sessionId: ${sessionId}`); } const cwd = opts?.cwd ?? stored.cwd; @@ -507,7 +519,7 @@ piCommand: process.env.PI_ACP_PI_COMMAND }); } catch (e) { -@@ -1897,7 +1960,7 @@ +@@ -1897,7 +1965,7 @@ } throw e; } @@ -516,7 +528,7 @@ const session = this.sessions.getOrCreate(sessionId, { cwd, mcpServers: opts?.mcpServers ?? [], -@@ -1917,6 +1980,7 @@ +@@ -1917,6 +1985,7 @@ } } async initialize(params) { @@ -524,7 +536,7 @@ const supportedVersion = 1; const requested = params.protocolVersion; return { -@@ -1933,7 +1997,8 @@ +@@ -1933,7 +2002,8 @@ }), agentCapabilities: { loadSession: true, @@ -534,7 +546,7 @@ promptCapabilities: { image: true, audio: false, -@@ -1942,8 +2007,7 @@ +@@ -1942,8 +2012,7 @@ sessionCapabilities: { // **UNSTABLE** ACP capability used by Zed's codex-acp adapter. // Enables a native session picker in clients that support it. @@ -544,7 +556,7 @@ } } }; -@@ -1953,7 +2017,7 @@ +@@ -1953,7 +2022,7 @@ throw RequestError3.invalidParams(`cwd must be an absolute path: ${params.cwd}`); } this.lastSessionCwd = params.cwd; @@ -553,7 +565,7 @@ const enableSkillCommands = getEnableSkillCommands(params.cwd); const session = await this.sessions.create({ cwd: params.cwd, -@@ -1994,14 +2058,14 @@ +@@ -1994,14 +2063,14 @@ this.cleanupFailedNewSession(session.sessionId, state); throw RequestError3.authRequired( { authMethods: getAuthMethods() }, @@ -570,7 +582,7 @@ ); } const { configOptions, models, modes } = await getSessionConfiguration(session.proc, { -@@ -2064,34 +2128,27 @@ +@@ -2064,34 +2133,27 @@ } async prompt(params) { const session = await this.restoreSession(params.sessionId); @@ -617,7 +629,7 @@ } if (cmd === "session") { const stats = await session.proc.getSessionStats(); -@@ -2112,13 +2169,8 @@ +@@ -2112,13 +2174,8 @@ } const text = lines.length ? lines.join("\n") : `Session stats: ${JSON.stringify(stats, null, 2)}`; @@ -633,7 +645,7 @@ return { stopReason: "end_turn" }; } if (cmd === "name") { -@@ -2418,22 +2470,24 @@ +@@ -2418,22 +2475,24 @@ enabled = !current; } await session.proc.setAutoCompaction(enabled); @@ -670,7 +682,7 @@ } async cancel(params) { const session = this.sessions.maybeGet(params.sessionId); -@@ -2473,7 +2527,7 @@ +@@ -2473,7 +2532,7 @@ mcpServers: params.mcpServers }); const proc = session.proc; @@ -679,7 +691,7 @@ this.sessions.closeAllExcept?.(session.sessionId); this.store.upsert({ sessionId: params.sessionId, -@@ -2482,7 +2536,7 @@ +@@ -2482,7 +2541,7 @@ }); const data = await proc.getMessages(); const messages = Array.isArray(data?.messages) ? data.messages : []; @@ -688,7 +700,7 @@ const role = String(m?.role ?? ""); if (role === "user") { const text = normalizePiMessageText(m?.content); -@@ -2501,16 +2555,14 @@ +@@ -2501,16 +2560,14 @@ if (text) { await this.conn.sessionUpdate({ sessionId: session.sessionId, @@ -708,7 +720,7 @@ const isError = Boolean(m?.isError); const isBash = isBashTool(toolName); if (isBash) { -@@ -2524,7 +2576,7 @@ +@@ -2524,7 +2581,7 @@ kind: "execute", status: "completed", content: bashTerminalContent(toolCallId), @@ -717,7 +729,7 @@ } }); await this.conn.sessionUpdate({ -@@ -2535,7 +2587,8 @@ +@@ -2535,7 +2592,8 @@ status: isError ? "failed" : "completed", _meta: { ...text2 ? bashTerminalOutputMeta(toolCallId, text2) : {}, @@ -727,7 +739,7 @@ } } }); -@@ -2549,6 +2602,7 @@ +@@ -2549,6 +2607,7 @@ title: toolName, kind: toolName === "read" ? "read" : toolName === "write" || toolName === "edit" ? "edit" : "other", status: "completed", @@ -735,7 +747,7 @@ rawInput: null, rawOutput: m } -@@ -2560,6 +2614,7 @@ +@@ -2560,6 +2619,7 @@ sessionUpdate: "tool_call_update", toolCallId, status: isError ? "failed" : "completed", @@ -743,7 +755,7 @@ content: text ? [{ type: "content", content: { type: "text", text } }] : null, rawOutput: m } -@@ -2607,6 +2662,7 @@ +@@ -2607,6 +2667,7 @@ return response; } async deleteSession(params) { @@ -751,7 +763,95 @@ const stored = this.store.get(params.sessionId); const piSession = findPiSession(params.sessionId); if (!stored && !piSession) { -@@ -2828,6 +2884,7 @@ +@@ -2630,15 +2691,12 @@ + async setSessionMode(params) { + const session = await this.restoreSession(params.sessionId); + const mode = String(params.modeId); +- if (!isThinkingLevel(mode)) { +- throw RequestError3.invalidParams(`Unknown modeId: ${mode}`); +- } +- await session.proc.setThinkingLevel(mode); +- void this.conn.sessionUpdate({ ++ const modes = await setVerifiedThinkingLevel(session.proc, mode); ++ await this.conn.sessionUpdate({ + sessionId: session.sessionId, + update: { + sessionUpdate: "current_mode_update", +- currentModeId: mode ++ currentModeId: modes.currentModeId + } + }); + await emitConfigOptionsUpdate(this.conn, session.sessionId, session.proc); +@@ -2653,15 +2711,12 @@ + if (configId === MODEL_CONFIG_ID) { + await setSessionModel(session.proc, params.value); + } else if (configId === THOUGHT_LEVEL_CONFIG_ID) { +- if (!isThinkingLevel(params.value)) { +- throw RequestError3.invalidParams(`Unknown thinking level: ${params.value}`); +- } +- await session.proc.setThinkingLevel(params.value); +- void this.conn.sessionUpdate({ ++ const modes = await setVerifiedThinkingLevel(session.proc, params.value); ++ await this.conn.sessionUpdate({ + sessionId: session.sessionId, + update: { + sessionUpdate: "current_mode_update", +- currentModeId: params.value ++ currentModeId: modes.currentModeId + } + }); + } else { +@@ -2672,20 +2727,18 @@ + } + }; + function isThinkingLevel(x) { +- return x === "off" || x === "minimal" || x === "low" || x === "medium" || x === "high" || x === "xhigh"; ++ return x === "off" || x === "minimal" || x === "low" || x === "medium" || x === "high" || x === "xhigh" || x === "max"; + } + async function getThinkingState(proc, pre) { +- let current = "medium"; +- const state = pre?.state ?? await (async () => { +- try { +- return await proc.getState(); +- } catch { +- return null; +- } +- })(); +- const tl = typeof state?.thinkingLevel === "string" ? state.thinkingLevel : null; +- if (tl && isThinkingLevel(tl)) current = tl; +- const available = ["off", "minimal", "low", "medium", "high", "xhigh"]; ++ const available = (await proc.getAvailableThinkingLevels())?.levels; ++ if (!Array.isArray(available) || available.length === 0 || available.some((level) => !isThinkingLevel(level)) || new Set(available).size !== available.length) { ++ throw new Error("Pi returned invalid supported thinking levels"); ++ } ++ const state = pre?.state ?? await proc.getState(); ++ const current = state?.thinkingLevel; ++ if (!isThinkingLevel(current) || !available.includes(current)) { ++ throw new Error("Pi returned an unsupported effective thinking level"); ++ } + return { + currentModeId: current, + availableModes: available.map((id) => ({ +@@ -2694,6 +2747,18 @@ + description: null + })) + }; ++} ++async function setVerifiedThinkingLevel(proc, requested) { ++ const before = await getThinkingState(proc); ++ if (!before.availableModes.some((mode) => mode.id === requested)) { ++ throw RequestError3.invalidParams(`Unsupported thinking level for the current model: ${requested}`); ++ } ++ await proc.setThinkingLevel(requested); ++ const after = await getThinkingState(proc); ++ if (after.currentModeId !== requested) { ++ throw new Error("Pi did not apply the requested thinking level"); ++ } ++ return after; + } + async function getSessionConfiguration(proc, pre) { + const [models, modes] = await Promise.all([getModelState(proc, pre), getThinkingState(proc, { state: pre?.state })]); +@@ -2828,6 +2893,7 @@ return 0; } function buildUpdateNotice() { @@ -759,7 +859,7 @@ try { const piVersion = spawnSync("pi", ["--version"], { encoding: "utf-8" }); const installed = (String(piVersion.stdout ?? "").trim() || String(piVersion.stderr ?? "").trim()).replace( -@@ -2848,6 +2905,7 @@ +@@ -2848,6 +2914,7 @@ } } function buildStartupInfo(opts) { @@ -767,7 +867,7 @@ void opts.fileCommands; const md = []; try { -@@ -2980,6 +3038,7 @@ +@@ -2980,6 +3047,7 @@ // src/index.ts if (process.argv.includes("--terminal-login")) { @@ -775,7 +875,7 @@ const { spawnSync: spawnSync2 } = await import("child_process"); const cmd = getPiCommand(process.env.PI_ACP_PI_COMMAND); const res = spawnSync2(cmd, [], { -@@ -3019,11 +3078,12 @@ +@@ -3019,11 +3087,12 @@ } }); var stream = ndJsonStream(input, output); @@ -790,7 +890,7 @@ } catch { } try { ---- /dev/null +--- a/dist/paperclip-runtime.js +++ b/dist/paperclip-runtime.js @@ -0,0 +1,577 @@ +/** Source for the helper embedded in patches/pi-acp@0.0.33.patch. */ @@ -1363,10 +1463,10 @@ + this.failed = previousFailure; + } + -+ response() { ++ response(stopReason ) { + return { + ...(this.observed ? { usage: { ...Object.fromEntries(Object.entries(this.total).filter(([name]) => !this.unknown.has(name ))), _meta: { paperclipPi: { provenance: this.compactionObserved ? "assistant_message_and_compaction_receipts" : "assistant_message_receipts", ...(this.costObserved && !this.costIncomplete ? { costUsd: this.cost, costSource: "pi_pricing_estimate" } : {}) } } } } : {}), -+ ...(this.failed ? { _meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "error", category: "service", title: "Pi provider request failed" } } } } } : {}), ++ ...(this.failed && stopReason !== "cancelled" ? { _meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "error", category: "service", title: "Pi provider request failed" } } } } } : {}), + }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f60cc365a7..0afe856098 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,7 +43,7 @@ patchedDependencies: hash: 6st5fzvttegjv64llnfegwwpca path: patches/acpx@0.12.0.patch acpx@0.13.1: - hash: btis2ixbxfyq422xwpnza3ncle + hash: j4huaoi2baejrlq7vdcgzlx54m path: patches/acpx@0.13.1.patch embedded-postgres@18.1.0-beta.16: hash: 55uhvnotpqyiy37rn3pqpukhei @@ -508,7 +508,7 @@ importers: version: 0.160.0 acpx: specifier: 0.13.1 - version: 0.13.1(patch_hash=btis2ixbxfyq422xwpnza3ncle) + version: 0.13.1(patch_hash=j4huaoi2baejrlq7vdcgzlx54m) ajv: specifier: ^8.20.0 version: 8.20.0 @@ -979,7 +979,7 @@ importers: version: 8.18.0 acpx: specifier: 0.13.1 - version: 0.13.1(patch_hash=btis2ixbxfyq422xwpnza3ncle) + version: 0.13.1(patch_hash=j4huaoi2baejrlq7vdcgzlx54m) ai: specifier: 7.0.130 version: 7.0.130(zod@4.4.3) @@ -7757,7 +7757,6 @@ packages: opencode-ai@1.18.34: resolution: {integrity: sha512-9WUS2T0t4HHDVzXvuwTHF0nvhXvZ9mQ0r+ozCvKdJu0LVoQpOzqAW4qWTC3ygNc5Oedcc7j+Oct24JYlQYnySA==} - cpu: [arm64, x64] os: [darwin, linux, win32] hasBin: true @@ -13500,7 +13499,7 @@ snapshots: - bare-buffer - react-native-b4a - acpx@0.13.1(patch_hash=btis2ixbxfyq422xwpnza3ncle): + acpx@0.13.1(patch_hash=j4huaoi2baejrlq7vdcgzlx54m): dependencies: '@agentclientprotocol/sdk': 1.4.0(zod@4.4.3) commander: 15.0.0 diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs index 316b417d31..f4446ec1af 100644 --- a/scripts/__tests__/grok-public-install-sandbox.test.mjs +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -38,6 +38,33 @@ test('only the scripts-disabled dependency download gets network access', () => assert.ok(values(args, '--env').includes('npm_config_ignore_scripts=true')); }); +test('Pi assembly gets bounded scratch capacity while preserving sandbox restrictions', () => { + const args = grokConsumerDockerArgs({ ...paths, download: true, temporarySizeMiB: 2048, command: ['node', '/consumer/node_modules/paperclipai/dist/index.js', 'runtime', 'setup', 'pi'] }); + assert.deepEqual(values(args, '--tmpfs'), ['/tmp:rw,nosuid,nodev,noexec,size=2048m,mode=1777']); + assert.deepEqual(values(args, '--memory'), ['3g']); + assert.ok(args.includes('--read-only')); + assert.deepEqual(values(args, '--cap-drop'), ['ALL']); + assert.deepEqual(values(args, '--security-opt'), ['no-new-privileges']); + for (const temporarySizeMiB of [0, 255, 2049, Infinity, '2048']) { + assert.throws(() => grokConsumerDockerArgs({ ...paths, temporarySizeMiB, command: ['node'] }), /bounded/); + } +}); + +test('only an offline runtime probe can execute its verified scratch snapshot', () => { + const command = ['node', '/packages/pi-public-install-probe.mjs', '/consumer/node_modules/@paperclipai/server']; + const args = grokConsumerDockerArgs({ ...paths, command, temporarySizeMiB: 2048, temporaryExecutable: true }); + assert.deepEqual(values(args, '--tmpfs'), ['/tmp:rw,nosuid,nodev,exec,size=2048m,mode=1777']); + assert.deepEqual(values(args, '--network'), ['none']); + assert.deepEqual(values(args, '--user'), ['1001:1001']); + assert.ok(args.includes('--read-only')); + assert.deepEqual(values(args, '--cap-drop'), ['ALL']); + assert.deepEqual(values(args, '--security-opt'), ['no-new-privileges']); + assert.throws(() => grokConsumerDockerArgs({ ...paths, command, download: true, temporaryExecutable: true }), /offline/); + assert.throws(() => grokConsumerDockerArgs({ ...paths, command, temporaryExecutable: 'true' }), /offline/); + const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); + assert.ok(source.includes("temporarySizeMiB: 2048, temporaryExecutable: true")); +}); + test('the separately provisioned executable is exposed read-only to the offline probe', () => { const prerequisite = '/private/staging/native/grok'; const args = grokConsumerDockerArgs({ ...paths, prerequisite, command: ['node', '/packages/probe.mjs', 'present'] }); diff --git a/scripts/__tests__/retain-runner-qualification-packages.test.mjs b/scripts/__tests__/retain-runner-qualification-packages.test.mjs new file mode 100644 index 0000000000..d5a9288ee3 --- /dev/null +++ b/scripts/__tests__/retain-runner-qualification-packages.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { retainRunnerQualificationPackages } from '../retain-runner-qualification-packages.mjs'; + +test('retention preserves public archives and runs the normal plugin prepack without modifying source', () => { + const root = mkdtempSync(join(tmpdir(), 'qualification-retention-test-')); + const sourceRevision = '1'.repeat(40), releaseVersion = '0.0.0-qualification.test'; + const put = (relative, content) => { + const file = join(root, relative); mkdirSync(join(file, '..'), { recursive: true }); + writeFileSync(file, content); return file; + }; + const json = (relative, value) => put(relative, JSON.stringify(value)); + try { + json('server/dist/build-info.json', { commit: sourceRevision }); + const originalPlugin = JSON.parse(readFileSync(new URL('../../packages/plugins/sandbox-providers/daytona/package.json', import.meta.url))); + const pluginManifest = json('packages/plugins/sandbox-providers/daytona/package.json', originalPlugin); + put('packages/plugins/sandbox-providers/daytona/dist/index.js', 'export const retained = true;\n'); + json('packages/plugins/sdk/package.json', { name: '@paperclipai/plugin-sdk', version: '0.3.1' }); + put('scripts/generate-plugin-package-json.mjs', readFileSync(new URL('../generate-plugin-package-json.mjs', import.meta.url))); + json('packages/paperclip-runner/package.json', { name: '@paperclipai/paperclip-runner', version: '0.0.0', private: true, files: ['dist'] }); + put('packages/paperclip-runner/dist/cli/eval-session.js', 'export const evaluation = true;\n'); + const publicArchives = ['@paperclipai/server', 'paperclipai', '@paperclipai/plugin-sdk'].map((name, i) => ({ name, file: put(`public-${i}.tgz`, `unchanged archive ${name}`) })); + const output = join(root, 'retained'); + const result = retainRunnerQualificationPackages({ repo: root, output, sourceRevision, releaseVersion, publicArchives }); + assert.equal(result.archives.length, 5); + for (const archive of result.archives) assert.equal(createHash('sha256').update(readFileSync(join(output, archive.file))).digest('hex'), archive.sha256); + for (const archive of publicArchives) assert.deepEqual(readFileSync(join(output, archive.file.split('/').at(-1))), readFileSync(archive.file)); + const plugin = result.archives.find(a => a.name === '@paperclipai/plugin-daytona'); + const packedManifest = JSON.parse(execFileSync('tar', ['-xOf', join(output, plugin.file), 'package/package.json'])); + assert.equal(packedManifest.dependencies['@paperclipai/plugin-sdk'], releaseVersion); + assert.equal(packedManifest.exports['.'].import, './dist/index.js'); + assert.equal(execFileSync('tar', ['-xOf', join(output, plugin.file), 'package/dist/index.js'], { encoding: 'utf8' }), 'export const retained = true;\n'); + assert.deepEqual(JSON.parse(readFileSync(pluginManifest)), originalPlugin); + assert.equal(existsSync(join(root, 'packages/plugins/sandbox-providers/daytona/package.dev.json')), false); + assert.throws(() => retainRunnerQualificationPackages({ repo: root, output, sourceRevision, releaseVersion, publicArchives }), /Never replace/); + assert.throws(() => retainRunnerQualificationPackages({ repo: root, output: join(root, 'wrong-source'), sourceRevision: '2'.repeat(40), releaseVersion, publicArchives })); + assert.equal(existsSync(join(root, 'wrong-source')), false); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/scripts/create-runner-remote-companion.mjs b/scripts/create-runner-remote-companion.mjs new file mode 100644 index 0000000000..c3cb74e0a3 --- /dev/null +++ b/scripts/create-runner-remote-companion.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +/** Run after the final server build and Linux daemon/provider-pack assembly. */ +import { createHash } from 'node:crypto'; +import { writeFile, realpath } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +const [directory, sourceRevision, ...extra] = process.argv.slice(2); +if (!directory || !/^[a-f0-9]{40}$/.test(sourceRevision ?? '') || extra.length) throw new Error('Usage: node scripts/create-runner-remote-companion.mjs DIRECTORY SOURCE_SHA'); +const root = await realpath(directory); +const { createRemotePiCompanionManifest } = await import(pathToFileURL(resolve('server/dist/services/native-runtime/remote-pi-companion.js')).href); +const manifest = await createRemotePiCompanionManifest(root, sourceRevision); +const bytes = JSON.stringify(manifest, null, 2) + '\n'; +await writeFile(resolve(root, 'companion.json'), bytes, { flag: 'wx', mode: 0o644 }); +console.log(JSON.stringify({ directory: root, sourceRevision, target: manifest.target, manifestSha256: createHash('sha256').update(bytes).digest('hex'), providerPackDigest: manifest.providerPackDigest, daemonSha256: manifest.daemonSha256 })); diff --git a/scripts/grok-public-install-sandbox.mjs b/scripts/grok-public-install-sandbox.mjs index 07dc920784..12b0c75d9e 100644 --- a/scripts/grok-public-install-sandbox.mjs +++ b/scripts/grok-public-install-sandbox.mjs @@ -7,18 +7,23 @@ export const GROK_PUBLIC_INSTALL_LIFECYCLE = [ 'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', ]; -export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite, temporarySizeMb = 256 }) { +export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite, temporarySizeMiB = 256, temporaryExecutable = false }) { if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0) { throw new Error('Public-install verification requires an unprivileged host user'); } - if (!Number.isSafeInteger(temporarySizeMb) || temporarySizeMb < 256 || temporarySizeMb > 2048) throw new Error('Invalid bounded public-install temporary size'); + if (!Number.isSafeInteger(temporarySizeMiB) || temporarySizeMiB < 256 || temporarySizeMiB > 2048) { + throw new Error('Public-install temporary storage must be bounded between 256 and 2048 MiB'); + } + if (typeof temporaryExecutable !== 'boolean' || (temporaryExecutable && download)) { + throw new Error('Executable runtime snapshots require an offline verification sandbox'); + } return [ 'run', '--rm', '--platform', 'linux/amd64', '--user', `${uid}:${gid}`, '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--pids-limit', '256', '--memory', '3g', '--network', download ? 'bridge' : 'none', - '--tmpfs', `/tmp:rw,nosuid,nodev,size=${temporarySizeMb}m,mode=1777`, + '--tmpfs', `/tmp:rw,nosuid,nodev,${temporaryExecutable ? 'exec' : 'noexec'},size=${temporarySizeMiB}m,mode=1777`, '--env', 'HOME=/tmp', '--env', 'npm_config_cache=/cache', '--env', 'npm_config_nodedir=/usr/local', '--env', 'npm_config_audit=false', '--env', 'npm_config_fund=false', diff --git a/scripts/pi-public-install-probe.mjs b/scripts/pi-public-install-probe.mjs new file mode 100644 index 0000000000..c41d154353 --- /dev/null +++ b/scripts/pi-public-install-probe.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +// Run in a credential-free public npm consumer after `paperclipai runtime setup pi`. +// Use the server's normal packaged daemon resolver; never submit a prompt. +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const [serverDirectory, ...extra] = process.argv.slice(2); +assert.ok(serverDirectory && extra.length === 0, 'Usage: pi-public-install-probe.mjs INSTALLED_SERVER_ROOT'); +for (const key of Object.keys(process.env)) { + assert.ok(!/(?:API_KEY|TOKEN|SECRET|PASSWORD)$/.test(key), `Unexpected credential variable: ${key}`); +} +assert.equal(process.env.PAPERCLIP_RUNNER_BINARY, undefined); +assert.equal(process.env.NODE_OPTIONS, undefined); +assert.equal(process.env.NODE_PATH, undefined); +const server = await realpath(serverDirectory); +assert.equal(JSON.parse(await readFile(join(server, 'package.json'), 'utf8')).name, '@paperclipai/server'); +const { resolvePaperclipRunnerBinary } = await import(pathToFileURL(join(server, 'dist/services/native-runtime/native-codex-runner.js'))); +const { createCapabilityRunnerdCodexTransport } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/live/runnerd-codex-transport.js'))); +const { QUALIFIED_ACPX_PROFILES } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/drivers/acpx/qualified-profiles.js'))); +assert.equal(QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, 19); +assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:b7647ebf97f802ca053ec3384c912bf0e8d18eba308d27397bb1d95a37220825'); +assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'qualificationModel'), false); +assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'reportedModelId'), false); +const daemon = resolvePaperclipRunnerBinary(); +assert.equal(await realpath(daemon), join(server, 'dist/vendor/paperclip-runner/bin/paperclip-runnerd')); +const root = await mkdtemp(join(tmpdir(), 'pi-public-install-probe-')); +const evidence = []; +const started = performance.now(); +const watchdog = setTimeout(() => { console.error('Pi public-install probe exceeded its cleanup deadline'); process.exit(1); }, 85_000); +let bundle; +let failure; +let settledMs; +try { + const workspace = join(root, 'workspace'); await mkdir(workspace); + bundle = createCapabilityRunnerdCodexTransport({ + provider: 'acpx', acpxAgent: 'pi', piThinkingLevel: 'low', acpxPermissionMode: 'deny-all', + runnerBinary: daemon, stateDirectory: join(root, 'state'), + environment: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' }, + onEvidence: value => evidence.push(value), + }); + try { + await bundle.transport.request('initialize', { clientInfo: { name: 'pi-public-install-verification', version: '1' } }); + await bundle.transport.request('thread/start', { + cwd: workspace, model: 'openrouter/deepseek/deepseek-v4-flash-0731', + baseInstructions: 'Credential-free admission probe. No prompt is submitted.', + permissions: 'paperclip-runner-workspace-read-only', dynamicTools: [], + }); + } catch (error) { failure = String(error); } + settledMs = Math.round(performance.now() - started); + await bundle.transport.close(); + assert.match(failure ?? '', /session\.open failed/); + assert.match(failure, /retryable=false, classification=session_ensure_failed/); + assert.doesNotMatch(failure, /timed out/); + assert.ok(settledMs < 60_000, `Pi admission took ${settledMs} ms`); + assert.ok(evidence.some(item => item.runnerExited === true && item.runnerExitCode === 0)); + for (const item of evidence) assert.deepEqual(item.childEnvironmentKeys, ['LANG', 'PATH']); + const state = JSON.parse(await readFile(join(root, 'state/runner/acpx-provider-state.json'), 'utf8')); + assert.equal(state.descriptor.agent, 'pi'); + assert.equal(state.descriptor.agentRuntimeVersion, '1.0.0'); + assert.equal(state.descriptor.piThinkingLevel, 'low'); + assert.equal(state.activeTurnId, null); + assert.equal(state.identity, null); + assert.equal(state.providerExitUnconfirmed, false); + console.log(JSON.stringify({ schema: 'paperclip.pi.public-npm-install.v1', target: `${process.platform}-${process.arch}`, normalPackagedDaemon: true, exactPiProfile: 19, runtime: '1.0.0', credentials: 'none', promptCalls: 0, settledMs, cleanRunnerExit: true })); +} finally { + try { await bundle?.transport.close(); } + finally { clearTimeout(watchdog); await rm(root, { recursive: true, force: true }); } +} diff --git a/scripts/retain-runner-qualification-packages.mjs b/scripts/retain-runner-qualification-packages.mjs new file mode 100644 index 0000000000..259293d40e --- /dev/null +++ b/scripts/retain-runner-qualification-packages.mjs @@ -0,0 +1,73 @@ +// Retain normal build/pack outputs for installed Product E2E and Runner evals. +// This runs only after the clean public installation and Pi admission pass. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; + +export function retainRunnerQualificationPackages({ repo, output, sourceRevision, releaseVersion, publicArchives, env = process.env }) { + assert.match(sourceRevision, /^[a-f0-9]{40}$/); + assert.equal(JSON.parse(readFileSync(join(repo, 'server/dist/build-info.json'))).commit, sourceRevision); + assert.ok(publicArchives.some(a => a.name === '@paperclipai/server')); + assert.ok(publicArchives.some(a => a.name === 'paperclipai')); + assert.ok(publicArchives.some(a => a.name === '@paperclipai/plugin-sdk')); + assert.equal(new Set(publicArchives.map(a => a.name)).size, publicArchives.length); + output = resolve(output); + assert.ok(!existsSync(output), 'Never replace retained qualification packages'); + mkdirSync(output, { recursive: true }); + const staging = mkdtempSync(join(tmpdir(), 'paperclip-qualification-pack-')); + const archives = []; + const hash = file => createHash('sha256').update(readFileSync(file)).digest('hex'); + const retain = (name, file) => { + const destination = join(output, basename(file)); + assert.ok(!existsSync(destination), 'Duplicate archive filename'); + cpSync(file, destination); + archives.push({ name, file: basename(file), sha256: hash(destination) }); + }; + const pack = (directory, name, ignoreScripts) => { + const previous = new Set(readdirSync(staging)); + execFileSync('npm', ['pack', ...(ignoreScripts ? ['--ignore-scripts'] : []), '--pack-destination', staging], { + cwd: directory, env: { ...env, npm_config_cache: join(staging, 'npm-cache'), npm_config_ignore_scripts: String(ignoreScripts) }, maxBuffer: 32 * 1024 * 1024, + }); + const files = readdirSync(staging).filter(file => file.endsWith('.tgz') && !previous.has(file)); + assert.equal(files.length, 1); + retain(name, join(staging, files[0])); + }; + try { + for (const archive of publicArchives) retain(archive.name, archive.file); + // Match the plugin's normal release prepack. Its source manifest omits + // the SDK dependency; the hook adds the publishable SDK version. + const pluginRelative = 'packages/plugins/sandbox-providers/daytona'; + const plugin = join(staging, pluginRelative); + mkdirSync(plugin, { recursive: true }); + cpSync(join(repo, pluginRelative, 'dist'), join(plugin, 'dist'), { recursive: true }); + const pluginManifest = JSON.parse(readFileSync(join(repo, pluginRelative, 'package.json'))); + writeFileSync(join(plugin, 'package.json'), JSON.stringify({ ...pluginManifest, version: releaseVersion })); + mkdirSync(join(staging, 'scripts')); + cpSync(join(repo, 'scripts/generate-plugin-package-json.mjs'), join(staging, 'scripts/generate-plugin-package-json.mjs')); + mkdirSync(join(staging, 'packages/plugins/sdk'), { recursive: true }); + const sdk = JSON.parse(readFileSync(join(repo, 'packages/plugins/sdk/package.json'))); + writeFileSync(join(staging, 'packages/plugins/sdk/package.json'), JSON.stringify({ ...sdk, version: releaseVersion })); + pack(plugin, pluginManifest.name, false); + // The separate private eval SDK is already built by pnpm build. Packing + // it does not substitute for the public server's packaged runner. + const runner = join(staging, 'runner'); + mkdirSync(runner); + const runnerManifest = JSON.parse(readFileSync(join(repo, 'packages/paperclip-runner/package.json'))); + for (const file of runnerManifest.files) { + const input = join(repo, 'packages/paperclip-runner', file); + if (existsSync(input)) cpSync(input, join(runner, file), { recursive: true }); + } + assert.ok(existsSync(join(runner, 'dist/cli/eval-session.js'))); + writeFileSync(join(runner, 'package.json'), JSON.stringify(runnerManifest)); + pack(runner, runnerManifest.name, true); + assert.equal(new Set(archives.map(a => a.name)).size, archives.length); + const receipt = { schema: 'paperclip.runner.qualification-packages/v1', sourceRevision, releaseVersion, publicInstallationVerified: true, piAdmissionVerified: true, providerCalls: 0, archives }; + writeFileSync(join(output, 'manifest.json'), JSON.stringify(receipt, null, 2) + '\n'); + return receipt; + } finally { + rmSync(staging, { recursive: true, force: true }); + } +} diff --git a/scripts/verify-grok-npm-install.mjs b/scripts/verify-grok-npm-install.mjs index ee1788f9df..dc8e7b62cb 100644 --- a/scripts/verify-grok-npm-install.mjs +++ b/scripts/verify-grok-npm-install.mjs @@ -10,6 +10,7 @@ import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { materializePublishManifest, prepareBundledPackage } from './prepare-bundled-package.mjs'; import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, grokConsumerDockerArgs } from './grok-public-install-sandbox.mjs'; +import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs'; const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..'); assert.equal(process.platform, 'linux', 'Run this verification on disposable EC2 Linux, not a developer host'); const root = mkdtempSync(join(tmpdir(), 'paperclip-grok-public-install-')); @@ -30,6 +31,7 @@ try { } } visit('@paperclipai/server'); + visit('paperclipai'); // Match release.sh's unified versioning in temporary staging directories. // Source manifests remain untouched, including independently versioned SDKs. run(process.execPath, [join(repo, 'scripts/build-standalone-public-packages.mjs')], repo); @@ -115,6 +117,29 @@ try { run(process.execPath, [join(repo, 'packages/paperclip-runner/scripts/provision-grok.mjs'), prerequisite]); isolated(['node', '/packages/probe.mjs', 'present'], { prerequisite }); console.log(JSON.stringify({ schema: 'paperclip.grok.public-npm-install.v1', sourceRevision, releaseVersion, lifecycleScriptsEnabled: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'none', consumerImage: GROK_PUBLIC_INSTALL_IMAGE, consumerUid, consumerLockPreserved: true, cleanNpmInstall: true, packageCount: needed.size, builtinLauncherPresent: true, separateGrokPackage: false, npmProvisionedBinary: false, missingPrerequisiteRejected: true, provisionedBinaryVerified: true, commandLeaseVerified: true, providerCalls: 0 })); + // Exercise Pi's public CLI and installed server, never a private workspace + // package or binary override. Public dependency downloads are explicit and + // isolated; the actual admission probe runs without a network or credentials. + const piProbe = join(assets, 'pi-public-install-probe.mjs'); + cpSync(join(repo, 'scripts/pi-public-install-probe.mjs'), piProbe); chmodSync(piProbe, 0o644); + // Pi assembles a bundled runtime in scratch space before atomic publication. + // Keep the existing sandbox and memory bound; only this download needs more + // temporary capacity than the smaller offline lifecycle probes. + const setup = isolated(['node', '/consumer/node_modules/paperclipai/dist/index.js', 'runtime', 'setup', 'pi'], { download: true, temporarySizeMiB: 2048 }).toString(); + const receipt = JSON.parse(setup.trim()); + assert.equal(receipt.status, 'installed_verified'); + assert.equal(receipt.target, 'linux-x64'); + assert.equal(readFileSync(join(consumer, 'package-lock.json'), 'utf8'), consumerLock, 'Pi setup must preserve the consumer dependency graph'); + // Verified launch leases also materialize the runtime in private scratch. + // Docker defaults tmpfs to noexec. The offline admission probe must execute + // its verified private snapshot while keeping lifecycle/download scratch noexec. + console.log(isolated(['node', '/packages/pi-public-install-probe.mjs', '/consumer/node_modules/@paperclipai/server'], { temporarySizeMiB: 2048, temporaryExecutable: true }).toString().trim()); + if (process.env.PAPERCLIP_RUNNER_QUALIFICATION_PACKAGES_DIR) { + const retained = retainRunnerQualificationPackages({ repo, output: process.env.PAPERCLIP_RUNNER_QUALIFICATION_PACKAGES_DIR, sourceRevision, releaseVersion, env, + publicArchives: [...needed].map((name, index) => ({ name, file: tarballs[index] })), + }); + console.log(JSON.stringify(retained)); + } } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/server/src/__tests__/agent-directory-probe.test.ts b/server/src/__tests__/agent-directory-probe.test.ts index 643f67144f..01a7b4b023 100644 --- a/server/src/__tests__/agent-directory-probe.test.ts +++ b/server/src/__tests__/agent-directory-probe.test.ts @@ -6,6 +6,7 @@ import { createRequire } from "node:module"; import childProcess, { execFileSync } from "node:child_process"; import { prepareAdapterExecutionTargetRuntime } from "@paperclipai/adapter-utils/execution-target"; import { captureDirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge"; +import { prepareSandboxManagedRuntime } from "@paperclipai/adapter-utils/sandbox-managed-runtime"; import { agentDirectoryBaselineDigest, agentDirectoryProbeProgram, observeLocalAgentDirectory, probeAgentDirectory, retireAgentDirectoryTransferScratch, agentDirectoryTransferCleanupProgram } from "../services/agent-directory-probe.js"; describe("stable live agent directory observation", () => { @@ -98,6 +99,51 @@ describe("stable live agent directory observation", () => { expect(probeAgentDirectory(remote).digest).toBe(agentDirectoryBaselineDigest(baseline)); } finally { await runtime?.cleanupWorkspaceSnapshot?.(); fs.rmSync(remote, { recursive: true, force: true }); } }); + it("keeps restart fallback transfer scratch outside the native memory directory", async () => { + const remoteCwd = `${root}-remote`; + const agentHome = join(remoteCwd, ".paperclip-runtime", "agent-files", "agent", "original-run"); + const scratch = join(remoteCwd, ".paperclip-runtime", "paperclip-runner", "agent-file-transfers", "agent", "original-run"); + fs.mkdirSync(agentHome, { recursive: true }); + fs.cpSync(root, agentHome, { recursive: true }); + const target = { kind: "remote" as const, transport: "sandbox" as const, remoteCwd, + // A newly bound controller target may have no native sync hooks. Exercise + // the production command/base64 fallback rather than a transport mock. + runner: { execute: async (input: Parameters[0]) => ({ + stdout: execFileSync(input.command, input.args ?? [], { cwd: input.cwd, input: input.stdin, encoding: "utf8", + env: { PATH: process.env.PATH, ...input.env }, timeout: input.timeoutMs, maxBuffer: 32 * 1024 * 1024 }), + stderr: "", exitCode: 0, signal: null, timedOut: false, pid: null, startedAt: new Date().toISOString(), + }) } }; + let runtime: Awaited> | undefined; + try { + const baseline = await captureDirectorySnapshot(root); + runtime = await prepareAdapterExecutionTargetRuntime({ target, runId: "restart-transfer", adapterKey: "agent-files", + workspaceLocalDir: root, workspaceRemoteDir: agentHome, runtimeRootDir: scratch, + syncWorkspace: true, workspaceInboundMode: "adopt_remote", workspaceBaseline: baseline, + workspaceGitSnapshot: null, workspaceFileMode: "all", workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] }); + const memory = "0123456789abcdef0123456789abcdef\n"; + fs.writeFileSync(join(agentHome, "notes", "memory.txt"), memory); + await runtime.restoreWorkspace(); + expect(fs.readFileSync(join(root, "notes", "memory.txt"), "utf8")).toBe(memory); + // A transfer must not introduce a reserved path that the product's own + // full agent-directory probe rejects, including after controller recovery. + expect(() => probeAgentDirectory(agentHome)).not.toThrow(); + expect(fs.existsSync(join(agentHome, ".paperclip-runtime"))).toBe(false); + expect(runtime.runtimeRootDir).toBe(scratch); + expect(fs.readdirSync(scratch)).toEqual([]); + } finally { await runtime?.cleanupWorkspaceSnapshot?.(); fs.rmSync(remoteCwd, { recursive: true, force: true }); } + }); + it.each(["outside", "reserved-root", "relative", "traversal", "trailing-slash", "nul"])("rejects %s transfer scratch before filesystem work", async kind => { + const reserved = join(root, ".paperclip-runtime"); + const invalid = { + outside: `${root}-foreign/scratch`, "reserved-root": reserved, relative: ".paperclip-runtime/scratch", + traversal: `${reserved}/../../scratch`, "trailing-slash": `${reserved}/scratch/`, nul: `${reserved}/scratch\0`, + }[kind]!; + await expect(prepareSandboxManagedRuntime({ + spec: { transport: "sandbox", provider: "fixture", sandboxId: "owned", remoteCwd: root, apiKey: null }, + adapterKey: "agent-files", client: {} as never, workspaceLocalDir: root, runtimeRootDir: invalid, + })).rejects.toThrow("Transfer scratch must remain within the lease runtime tree"); + expect(fs.existsSync(reserved)).toBe(false); + }); it.each(["file", "extra-directory", "symlink", "race"])("fails closed on %s in transfer-owned scratch without deleting contents", kind => { const parent = join(root, ".paperclip-runtime"), scratch = join(parent, "agent-files"); fs.mkdirSync(scratch, { recursive: true }); diff --git a/server/src/__tests__/agent-directory-working-copies.test.ts b/server/src/__tests__/agent-directory-working-copies.test.ts index 827139c5a9..69fbb1c7d0 100644 --- a/server/src/__tests__/agent-directory-working-copies.test.ts +++ b/server/src/__tests__/agent-directory-working-copies.test.ts @@ -225,6 +225,8 @@ describe("persistent agent directories", () => { await copies.recoverStopped(); expect((await copies.get(companyId, secondId))?.processStoppedAt).toBeNull(); await stop(secondId); + // Make the deferred maintenance retry due after the new owner stop proof. + await db.update(agentInstructionWorkingCopies).set({ nextAttemptAt: null }).where(eq(agentInstructionWorkingCopies.runId, secondId)); await copies.recoverStopped(); } expect(await copies.get(companyId, secondId)).toMatchObject({ state: "saved", receipt: { cleanupPending: false } }); @@ -556,7 +558,8 @@ describe("persistent agent directories", () => { } expect(execute).toHaveBeenCalledTimes(2); expect(execute).toHaveBeenLastCalledWith(expect.objectContaining({ lease: expect.objectContaining({ id: leaseId, providerLeaseId: "original-sandbox" }), - command: "rm", args: ["-rf", "--", executionRoot], bypassSession: true })); + command: "rm", args: ["-rf", "--", executionRoot, + path.posix.join(remoteCwd, ".paperclip-runtime", "paperclip-runner", "agent-file-transfers", agentId, copy.runId)], bypassSession: true })); await copies.recoverCaptured(); expect(execute).toHaveBeenCalledTimes(2); await copies.release(companyId, copy.runId); @@ -856,6 +859,9 @@ describe("persistent agent directories", () => { await fs.mkdir(path.join(root, "build")); await fs.writeFile(path.join(root, "build", "personal.txt"), "cache-like names are still agent files"); const first = await prepare(); + const firstScratch = path.join(remoteCwd, ".paperclip-runtime", "paperclip-runner", "agent-file-transfers", agentId, first.runId); + expect(await fs.readdir(firstScratch)).toEqual([]); + await expect(fs.stat(path.join(first.executionRoot, ".paperclip-runtime"))).rejects.toMatchObject({ code: "ENOENT" }); expect(await fs.readFile(path.join(first.executionRoot, "build", "personal.txt"), "utf8")).toBe("cache-like names are still agent files"); await fs.mkdir(path.join(first.executionRoot, "notes")); await fs.writeFile(path.join(first.executionRoot, "notes", "bytes.bin"), Buffer.from([0, 128, 255])); @@ -865,6 +871,7 @@ describe("persistent agent directories", () => { expect((await execFile("git", ["-C", remoteCwd, "status", "--porcelain", "--untracked-files=all"])).stdout).toBe("?? task-only.txt\n"); expect((await copies.collectStopped({ companyId, runId: first.runId, target: executionTarget }))?.state).toBe("saved"); await copies.release(companyId, first.runId); + await expect(fs.stat(firstScratch)).rejects.toMatchObject({ code: "ENOENT" }); expect((await copies.get(companyId, first.runId))?.receipt?.baseline).toBeUndefined(); await expect(fs.stat(first.localRoot)).rejects.toMatchObject({ code: "ENOENT" }); await fs.rm(remoteCwd, { recursive: true }); @@ -1188,8 +1195,8 @@ describe("persistent agent directories", () => { const execute = vi.fn(), restoreWorkspace = vi.fn(), cleanupWorkspaceSnapshot = vi.fn().mockResolvedValue(undefined); const executionTarget = { kind: "remote" as const, transport: "sandbox" as const, environmentId, leaseId, remoteCwd, runner: { execute } }; const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" }); - const stage = vi.spyOn(executionTargetTools, "prepareAdapterExecutionTargetRuntime").mockImplementation(async () => ({ target: executionTarget, - workspaceRemoteDir: path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, runId), runtimeRootDir: null, + const stage = vi.spyOn(executionTargetTools, "prepareAdapterExecutionTargetRuntime").mockImplementation(async options => ({ target: executionTarget, + workspaceRemoteDir: path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, runId), runtimeRootDir: options.runtimeRootDir ?? null, assetDirs: {}, additionalSourceDirs: {}, additionalSourceFailures: [], workspaceSyncSnapshot: null, restoreWorkspace, cleanupWorkspaceSnapshot })); try { const copy = (await copies.prepare({ ...target(), runId, cwd: home, target: executionTarget }))!; diff --git a/server/src/__tests__/agent-instruction-working-copies.test.ts b/server/src/__tests__/agent-instruction-working-copies.test.ts index 8cad9df070..bd29eb5bc9 100644 --- a/server/src/__tests__/agent-instruction-working-copies.test.ts +++ b/server/src/__tests__/agent-instruction-working-copies.test.ts @@ -300,6 +300,10 @@ describe("registered run instruction copies", () => { expect((await revisions.readCurrent(target(), board()))?.content).toBe(initial); expect((await copies.list(companyId, agentId, board()))[0]).toMatchObject({ state: "pending_collection", content: null }); await appendHeartbeatRunEvent(db, { ...target(), runId: copy.runId, eventType: "native.local_process_stopped", stream: "system" }); + // Recovery scheduled a retry while stop authority was missing. Make that + // retry due before asking the restarted controller to collect the copy. + await db.update(agentInstructionWorkingCopies).set({ nextAttemptAt: new Date(0) }) + .where(eq(agentInstructionWorkingCopies.runId, copy.runId)); await copies.recoverStopped(); expect((await revisions.readCurrent(target(), board()))?.content).toBe("edit before controller restart"); }); diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 395b7bc6c6..6df1000739 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -1091,12 +1091,23 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const companyId = randomUUID(); const assignedAgentId = randomUUID(); const replacementAgentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: `Chat Test ${companyId.slice(0, 8)}`, - issuePrefix: `C${companyId.replace(/-/g, "").toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + let inserted = false; + // Retired fixtures retain company rows. Retry only the short-prefix unique + // conflict; every other database error must still fail the test immediately. + for (let attempt = 0; attempt < companyPrefixAttempts; attempt += 1) { + const [company] = await db.insert(companies).values({ + id: companyId, + name: `Chat Test ${companyId.slice(0, 8)}`, + issuePrefix: nextIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }).onConflictDoNothing({ target: companies.issuePrefix }).returning({ id: companies.id }); + if (company) { + inserted = true; + fixtureCompanies.add(companyId); + break; + } + } + if (!inserted) throw new Error(`Could not allocate a chat fixture company prefix after ${companyPrefixAttempts} attempts`); const now = new Date(); await db .insert(authUsers) @@ -1614,6 +1625,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { await service.processPendingPublications(); const providerRuntime = fakeRuntime.endpoints.get(endpointId); if (providerRuntime) providerRuntime.posts.length = 0; + return setupFollowUpMessageId; } async function configuredSlackEndpoint( @@ -28229,8 +28241,18 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { }), trigger: "direct_message", }); - await qualifySetupRoundTrip(service, endpoint.id, userId); + const setupMessageId = await qualifySetupRoundTrip(service, endpoint.id, userId); await service.test(endpoint.id, "owner-user"); + if (provider === "telegram") { + // Setup dispatches receipt cleanup asynchronously. Finish it before + // measuring reaction removals owned by this fixture's working run. + await service.processPendingReceiptReactions(); + await waitForProcessedReceiptRemoval(endpoint.id, { + threadId: thread.thread.id, + messageId: setupMessageId, + emoji: "eyes", + }); + } const [conversation] = await service.listConversations(endpoint.id); const runId = randomUUID(); await db.insert(heartbeatRuns).values({ @@ -62562,7 +62584,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { }, ); - it("orders reversed GitHub edit and delete callbacks behind their durable root", async () => { + it.each([false, true])("orders reversed GitHub edit and delete callbacks behind their durable root (root processed before replay: %s)", async (rootProcessedFirst) => { const fixture = await seedCompany(); const deferred: Array<() => void | Promise> = []; const { callbacks, endpoint, service, wakeup, webhookSecret } = @@ -62665,8 +62687,28 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { // One root-conversation drain plus one durable-ingress callback per HTTP // request is queued. The duplicate delivery callback becomes a no-op. expect(deferred).toHaveLength(4); + if (rootProcessedFirst) { + await deferred.shift()?.(); + await vi.waitFor(async () => { + const [root] = await db + .select({ state: chatDeliveries.state }) + .from(chatDeliveries) + .where(eq(chatDeliveries.endpointId, endpoint.id)); + expect(root.state).toBe("processed"); + }); + } await drainDeferred(); + // The scheduler callbacks start background promises. Wait for durable + // replay admission before draining the conversation work it schedules. await vi.waitFor(async () => { + const deliveries = await db + .select({ id: chatDeliveries.id }) + .from(chatDeliveries) + .where(eq(chatDeliveries.endpointId, endpoint.id)); + expect(deliveries).toHaveLength(3); + }); + await vi.waitFor(async () => { + await drainDeferred(); const deliveries = await db .select({ eventKind: chatDeliveries.eventKind, diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts index 5cf2c2f9fd..df72db3843 100644 --- a/server/src/__tests__/environment-run-orchestrator.test.ts +++ b/server/src/__tests__/environment-run-orchestrator.test.ts @@ -12,6 +12,7 @@ const mockUpdateExecutionWorkspace = vi.hoisted(() => vi.fn()); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockLoggerInfo = vi.hoisted(() => vi.fn()); const mockGetEnvironment = vi.hoisted(() => vi.fn()); +const mockGetLease = vi.hoisted(() => vi.fn()); vi.mock("../services/environment-execution-target.js", () => ({ resolveEnvironmentExecutionTarget: mockResolveEnvironmentExecutionTarget, @@ -30,6 +31,7 @@ vi.mock("../services/environments.js", () => ({ environmentService: vi.fn(() => ({ ensureLocalEnvironment: vi.fn(), getById: mockGetEnvironment, + getLeaseById: mockGetLease, acquireLease: vi.fn(), releaseLease: vi.fn(), updateLeaseMetadata: mockUpdateLeaseMetadata, @@ -815,3 +817,56 @@ describe("admitted native lifecycle recovery", () => { expect(environment.config.reuseLease).toBe(false); }); }); + +describe("live remote runner lease reattachment", () => { + const input = { + companyId: "company-1", selectedEnvironmentId: "env-1", localEnvironmentId: "local", + adapterType: "paperclip_runner", admittedLifecycleMode: "per_turn" as const, + issueId: "task-1", heartbeatRunId: "run-1", agentId: "agent-1", + persistedExecutionWorkspace: { id: "ew-1", mode: "shared_workspace" as const }, + executionWorkspaceSettings: null, + reattachRemoteLease: { leaseId: "lease-1", providerLeaseId: "original-sandbox", remoteCwd: "/remote/workspace" }, + }; + const originalLease = () => makeLease({ + provider: "daytona", providerLeaseId: "original-sandbox", issueId: "task-1", executionWorkspaceId: "ew-1", + metadata: { agentId: "agent-1", remoteCwd: "/remote/workspace", + sandboxLeaseAcquisition: { outcome: "created", providerLeaseId: "original-sandbox" } }, + }); + beforeEach(() => { + vi.clearAllMocks(); + mockGetEnvironment.mockResolvedValue({ ...makeEnvironment("sandbox"), config: { provider: "daytona", reuseLease: false } }); + mockGetLease.mockResolvedValue(originalLease()); + }); + it("reattaches the original active ephemeral lease without acquiring or resuming a sandbox", async () => { + const savedLease = originalLease(); + mockGetLease.mockResolvedValue(savedLease); + const runtime = makeMockRuntime(); + const result = await environmentRunOrchestrator({} as never, { environmentRuntime: runtime }).acquireForRun(input); + expect(mockGetLease).toHaveBeenCalledWith("lease-1"); + expect(runtime.acquireRunLease).not.toHaveBeenCalled(); + expect(result.lease).toEqual(savedLease); + expect(result.leaseContext.executionWorkspaceId).toBe("ew-1"); + expect(result.environment.config.reuseLease).toBe(false); + }); + it.each([ + { companyId: "foreign" }, { environmentId: "foreign" }, { heartbeatRunId: "another-run" }, + { issueId: "another-task" }, { executionWorkspaceId: "another-workspace" }, + { providerLeaseId: "replacement" }, { provider: "another-provider" }, + { status: "released" }, { expiresAt: new Date(0) }, { releasedAt: new Date() }, { cleanupStatus: "pending" }, + { metadata: { agentId: "another-agent", remoteCwd: "/remote/workspace" } }, + { metadata: { agentId: "agent-1", remoteCwd: "/other/workspace" } }, + ] as Partial[])("fails closed before provider acquisition for a mismatched lease %j", async (override) => { + mockGetLease.mockResolvedValue({ ...originalLease(), ...override }); + const runtime = makeMockRuntime(); + await expect(environmentRunOrchestrator({} as never, { environmentRuntime: runtime }).acquireForRun(input)) + .rejects.toThrow("native_remote_recovery_lease_mismatch"); + expect(runtime.acquireRunLease).not.toHaveBeenCalled(); + }); + it("never replaces a missing original lease", async () => { + mockGetLease.mockResolvedValue(null); + const runtime = makeMockRuntime(); + await expect(environmentRunOrchestrator({} as never, { environmentRuntime: runtime }).acquireForRun(input)) + .rejects.toThrow("native_remote_recovery_lease_mismatch"); + expect(runtime.acquireRunLease).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index 216e213666..b683491a82 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -6679,13 +6679,24 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }); }); - it("destroys scoped reusable plugin-backed sandbox leases", async () => { + it.each([ + { status: "active", scope: "workspace" }, + { status: "failed", scope: "workspace" }, + { status: "failed", scope: "issue" }, + ] as const)("destroys $status reusable plugin-backed sandbox leases scoped to an $scope", async ({ status, scope }) => { const { pluginId, companyId, runId, executionWorkspaceId, reusableLease } = await seedReusablePluginSandboxLease(); await db .update(heartbeatRuns) - .set({ status: "succeeded" }) + .set({ status: status === "failed" ? "failed" : "succeeded" }) .where(eq(heartbeatRuns.id, runId)); + if (status === "failed") await environmentService(db).releaseLease(reusableLease.id, "failed"); + const issueId = randomUUID(); + if (scope === "issue") { + await db.insert(issues).values({ id: issueId, companyId, title: "Failed reusable lease cleanup", status: "done", priority: "medium" }); + await db.update(environmentLeases).set({ issueId }).where(eq(environmentLeases.id, reusableLease.id)); + } + const selection = scope === "issue" ? { issueId } : { executionWorkspaceId }; const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), @@ -6699,9 +6710,12 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { } as unknown as PluginWorkerManager; const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); + // A known issue/workspace id does not authorize another company's teardown. + expect(await runtimeWithPlugin.destroyReusableSandboxLeases({ companyId: randomUUID(), ...selection })).toEqual([]); + expect(workerManager.call).not.toHaveBeenCalled(); const destroyed = await runtimeWithPlugin.destroyReusableSandboxLeases({ companyId, - executionWorkspaceId, + ...selection, failureReason: "execution_workspace_closed", }); @@ -6724,9 +6738,10 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }); }); - it("does not destroy a scoped reusable lease while its run is active", async () => { + it.each(["active", "failed"] as const)("does not destroy a %s scoped reusable lease while its run is active", async (status) => { const { pluginId, companyId, executionWorkspaceId, reusableLease } = await seedReusablePluginSandboxLease(); + if (status === "failed") await environmentService(db).releaseLease(reusableLease.id, "failed"); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), call: vi.fn(async () => undefined), @@ -6752,9 +6767,106 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { expect(workerManager.call).not.toHaveBeenCalled(); await expect( environmentService(db).getLeaseById(reusableLease.id), - ).resolves.toMatchObject({ status: "active" }); + ).resolves.toMatchObject({ status }); }); + it.each(["issue", "workspace", "environment"] as const)( + "durably claims failed reusable cleanup before %s teardown and recovers a provider failure", + async (scope) => { + const seeded = await seedReusablePluginSandboxLease(); + await db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, seeded.runId)); + await environmentService(db).releaseLease(seeded.reusableLease.id, "failed"); + const issueId = randomUUID(); + if (scope === "issue") { + await db.insert(issues).values({ id: issueId, companyId: seeded.companyId, title: "Cleanup crash window", status: "done" }); + await db.update(environmentLeases).set({ issueId }).where(eq(environmentLeases.id, seeded.reusableLease.id)); + } + let enterProvider!: () => void; + let finishProvider!: () => void; + const entered = new Promise((resolve) => { enterProvider = resolve; }); + const finish = new Promise((resolve) => { finishProvider = resolve; }); + let unavailable = true; + const call = vi.fn(async (_id: string, method: string) => { + if (method !== "environmentDestroyLease") throw new Error(`Unexpected method ${method}`); + if (unavailable) { + enterProvider(); + await finish; + throw new Error("Provider response lost"); + } + return { providerLeaseId: seeded.reusableLease.providerLeaseId, state: "destroyed" }; + }); + const makeRuntime = () => environmentRuntimeService(db, { pluginWorkerManager: { + isRunning: () => true, call, + getWorker: () => ({ supportedMethods: ["environmentDestroyLease"] }), + } as unknown as PluginWorkerManager }); + const runtime = makeRuntime(); + const close = (service: ReturnType) => scope === "environment" + ? service.destroyReusableSandboxLeasesForEnvironment({ environmentId: seeded.environment.id }) + : service.destroyReusableSandboxLeases({ companyId: seeded.companyId, + ...(scope === "issue" ? { issueId } : { executionWorkspaceId: seeded.executionWorkspaceId }) }); + const closing = close(runtime); + try { + await entered; + // This independently persisted row is what survives controller loss + // during the provider call. No provider receipt has arrived yet. + expect(await environmentService(db).getLeaseById(seeded.reusableLease.id)).toMatchObject({ + status: "pending_cleanup", cleanupStatus: "failed", companyId: seeded.companyId, + providerLeaseId: seeded.reusableLease.providerLeaseId, + metadata: { pendingCleanupAttemptId: expect.any(String), pendingCleanupInFlight: true, + pendingCleanupLeaseExpiresAtMs: expect.any(Number) }, + }); + await close(makeRuntime()); + expect(await heartbeatService(db, { environmentRuntime: makeRuntime() }).sweepPendingCleanupLeases({ backoffMs: 0 })) + .toEqual({ swept: 0, destroyed: 0, capped: 0 }); + expect(call).toHaveBeenCalledOnce(); + } finally { + finishProvider(); + await closing; + } + expect(await environmentService(db).getLeaseById(seeded.reusableLease.id)).toMatchObject({ + status: "pending_cleanup", metadata: { pendingCleanupInFlight: false }, + }); + unavailable = false; + expect(await heartbeatService(db, { environmentRuntime: makeRuntime() }).sweepPendingCleanupLeases({ backoffMs: 0 })) + .toEqual({ swept: 1, destroyed: 1, capped: 0 }); + expect(call).toHaveBeenCalledTimes(2); + expect(await environmentService(db).getLeaseById(seeded.reusableLease.id)).toMatchObject({ + status: "expired", cleanupStatus: "success", metadata: { remoteExecutionTermination: { state: "destroyed" } }, + }); + }, + ); + + it.each(["queued", "scheduled_retry", "running"] as const)( + "fences scoped cleanup when its holding run becomes %s after selection", async (status) => { + const seeded = await seedReusablePluginSandboxLease(); + await db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, seeded.runId)); + await environmentService(db).releaseLease(seeded.reusableLease.id, "failed"); + const originalService = environmentsModule.environmentService; + const serviceSpy = vi.spyOn(environmentsModule, "environmentService").mockImplementation((client) => { + const service = originalService(client); + return { ...service, getById: async (id: string) => { + const environment = await service.getById(id); + if (id === seeded.environment.id) { + await db.update(heartbeatRuns).set({ status }).where(eq(heartbeatRuns.id, seeded.runId)); + } + return environment; + } }; + }); + const call = vi.fn(async () => undefined); + const runtime = environmentRuntimeService(db, { pluginWorkerManager: { + isRunning: () => true, call, getWorker: () => ({ supportedMethods: ["environmentDestroyLease"] }), + } as unknown as PluginWorkerManager }); + try { + expect(await runtime.destroyReusableSandboxLeases({ companyId: seeded.companyId, + executionWorkspaceId: seeded.executionWorkspaceId })).toEqual([]); + expect(call).not.toHaveBeenCalled(); + expect(await originalService(db).getLeaseById(seeded.reusableLease.id)).toMatchObject({ status: "failed" }); + } finally { + serviceSpy.mockRestore(); + } + }, + ); + it("destroys reusable plugin-backed sandbox leases scoped to an environment", async () => { const { pluginId, runId, reusableLease } = await seedReusablePluginSandboxLease(); // The holding run is finished, so the reservation is stale and destroyable. @@ -6796,9 +6908,51 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }); }); - it("keeps a reusable lease held by an in-flight run out of the environment-scoped destroy", async () => { + it("destroys a failed reusable sandbox after successful release before deleting its environment", async () => { + const { pluginId, companyId, runId, environment, reusableLease } = await seedReusablePluginSandboxLease(); + const workerManager = { + isRunning: vi.fn((id: string) => id === pluginId), + call: vi.fn(async (_pluginId: string, method: string) => { + if (method === "environmentReleaseLease") { + return { providerLeaseId: reusableLease.providerLeaseId, state: "stopped" }; + } + if (method === "environmentDestroyLease") { + // The provider call must already have a durable cleanup reference. + expect(await environmentService(db).getLeaseById(reusableLease.id)).toMatchObject({ + status: "pending_cleanup", environmentId: environment.id, + }); + return { providerLeaseId: reusableLease.providerLeaseId, state: "destroyed" }; + } + throw new Error(`Unexpected plugin method: ${method}`); + }), + getWorker: vi.fn(() => ({ supportedMethods: ["environmentReleaseLease", "environmentDestroyLease"] })), + } as unknown as PluginWorkerManager; + const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); + await db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, runId)); + + const released = await runtimeWithPlugin.releaseRunLeases(runId, "failed", undefined, undefined, true); + expect(released[0]?.lease).toMatchObject({ + id: reusableLease.id, status: "failed", cleanupStatus: "success", + metadata: { remoteExecutionTermination: { state: "stopped" } }, + }); + const result = await runtimeWithPlugin.destroyReusableSandboxLeasesForEnvironment({ + environmentId: environment.id, failureReason: "environment_deleted", + }); + expect(result).toEqual({ destroyed: 1, failed: 0, skippedLiveRun: 0 }); + expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentDestroyLease", + expect.objectContaining({ companyId, environmentId: environment.id, providerLeaseId: reusableLease.providerLeaseId }), + 31234); + await expect(environmentService(db).getLeaseById(reusableLease.id)).resolves.toMatchObject({ + status: "expired", cleanupStatus: "success", + metadata: { remoteExecutionTermination: { state: "destroyed" } }, + }); + expect((await environmentService(db).removeIfDeletable(environment.id))?.id).toBe(environment.id); + }); + + it.each(["active", "failed"] as const)("keeps a %s reusable lease held by an in-flight run out of the environment-scoped destroy", async (status) => { const { pluginId, reusableLease } = await seedReusablePluginSandboxLease(); // seedEnvironment leaves the holding run in `running` status. + if (status === "failed") await environmentService(db).releaseLease(reusableLease.id, "failed"); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), @@ -6816,7 +6970,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { expect(workerManager.call).not.toHaveBeenCalled(); // The lease keeps its reusable status, so the delete guard still blocks. await expect(environmentService(db).getLeaseById(reusableLease.id)).resolves.toMatchObject({ - status: "active", + status, leasePolicy: "reuse_by_environment", }); }); diff --git a/server/src/__tests__/environment-service.test.ts b/server/src/__tests__/environment-service.test.ts index 5167ae4037..cfa81980bf 100644 --- a/server/src/__tests__/environment-service.test.ts +++ b/server/src/__tests__/environment-service.test.ts @@ -636,6 +636,18 @@ describeEmbeddedPostgres("environmentService leases", () => { expect(releasedImpact?.reusableSandboxLeaseCount).toBe(1); expect(await svc.removeIfDeletable(environmentId)).toBeNull(); + // A failed run can release its reusable sandbox successfully without + // destroying it. The provider handle still needs environment-scoped cleanup. + await svc.releaseLease(lease.id, "failed", { + failureReason: "adapter_or_run_failure", + cleanupStatus: "success", + }); + const failedImpact = await svc.getDeleteBlastRadius(environmentId); + expect.soft(failedImpact?.canDelete).toBe(false); + expect.soft(failedImpact?.deleteBlockedReasons).toContain("reusable_sandbox_lease"); + expect.soft(failedImpact?.reusableSandboxLeaseCount).toBe(1); + expect(await svc.removeIfDeletable(environmentId)).toBeNull(); + const rows = await db.select().from(environments).where(eq(environments.id, environmentId)); expect(rows).toHaveLength(1); const storedLease = await svc.getLeaseById(lease.id); diff --git a/server/src/__tests__/file-resources-git-scan-load.test.ts b/server/src/__tests__/file-resources-git-scan-load.test.ts index 6159af858d..a694a093ec 100644 --- a/server/src/__tests__/file-resources-git-scan-load.test.ts +++ b/server/src/__tests__/file-resources-git-scan-load.test.ts @@ -134,50 +134,65 @@ describe("workspace Git scan route load regression", () => { ...unavailableMethods(), }; const app = createLoadApp(db, companyId, service); - const pendingResponses = Array.from({ length: 500 }, (_, index) => request(app) + // Model one busy API server, not 500 independent ephemeral listeners whose + // accept/close races can reset clients before they reach the scheduler. + const server = app.listen(0, "127.0.0.1"); + await new Promise(resolve => server.once("listening", resolve)); + const pendingResponses = Array.from({ length: 500 }, (_, index) => request(server) .get(`/api/issues/issue-${index}/file-resources/list`) .set("x-test-actor", `actor-${index % 73}`) .query({ mode: "changed" }) .then((response) => response)); + // Observe every client rejection immediately, including on assertion failure. + const settledResponses = Promise.allSettled(pendingResponses); + try { + await vi.waitFor( + () => expect(scheduler.snapshot().totals.singleFlightJoins).toBe(498), + { timeout: 15_000, interval: 20 }, + ); + const loadedSnapshot = scheduler.snapshot(); + expect(loadedSnapshot).toMatchObject({ activeCount: 2, queuedCount: 0, inFlightCount: 2 }); - await vi.waitFor( - () => expect(scheduler.snapshot().totals.singleFlightJoins).toBe(498), - { timeout: 15_000, interval: 20 }, - ); - const loadedSnapshot = scheduler.snapshot(); - expect(loadedSnapshot).toMatchObject({ activeCount: 2, queuedCount: 0, inFlightCount: 2 }); + const healthLatencies: number[] = []; + for (let index = 0; index < 25; index += 1) { + const startedAt = performance.now(); + const response = await request(server).get("/api/health"); + healthLatencies.push(performance.now() - startedAt); + expect(response.status).toBe(200); + } + healthLatencies.sort((left, right) => left - right); + const healthP99Ms = healthLatencies[Math.ceil(healthLatencies.length * 0.99) - 1]!; + expect(healthP99Ms).toBeLessThan(250); - const healthLatencies: number[] = []; - for (let index = 0; index < 25; index += 1) { - const startedAt = performance.now(); - const response = await request(app).get("/api/health"); - healthLatencies.push(performance.now() - startedAt); - expect(response.status).toBe(200); + releaseScans(); + const responses = (await settledResponses).map(result => { + if (result.status === "rejected") throw result.reason; + return result.value; + }); + const outcomeCounts = responses.reduce>((counts, response) => { + counts[response.status] = (counts[response.status] ?? 0) + 1; + return counts; + }, {}); + expect(outcomeCounts).toEqual({ 200: 500 }); + expect({ runnerCalls, peakActive }).toEqual({ runnerCalls: 2, peakActive: 2 }); + expect(scheduler.snapshot()).toMatchObject({ activeCount: 0, queuedCount: 0, inFlightCount: 0 }); + + console.info("workspace Git scan regression metrics", { + requests: responses.length, + repositories: roots.length, + underlyingScans: runnerCalls, + peakActive, + peakQueued: loadedSnapshot.queuedCount, + outcomes: outcomeCounts, + healthP99Ms: Math.round(healthP99Ms * 100) / 100, + unreapedChildCount: 0, + }); + } finally { + releaseScans(); + server.closeAllConnections(); + await settledResponses; + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); } - healthLatencies.sort((left, right) => left - right); - const healthP99Ms = healthLatencies[Math.ceil(healthLatencies.length * 0.99) - 1]!; - expect(healthP99Ms).toBeLessThan(250); - - releaseScans(); - const responses = await Promise.all(pendingResponses); - const outcomeCounts = responses.reduce>((counts, response) => { - counts[response.status] = (counts[response.status] ?? 0) + 1; - return counts; - }, {}); - expect(outcomeCounts).toEqual({ 200: 500 }); - expect({ runnerCalls, peakActive }).toEqual({ runnerCalls: 2, peakActive: 2 }); - expect(scheduler.snapshot()).toMatchObject({ activeCount: 0, queuedCount: 0, inFlightCount: 0 }); - - console.info("workspace Git scan regression metrics", { - requests: responses.length, - repositories: roots.length, - underlyingScans: runnerCalls, - peakActive, - peakQueued: loadedSnapshot.queuedCount, - outcomes: outcomeCounts, - healthP99Ms: Math.round(healthP99Ms * 100) / 100, - unreapedChildCount: 0, - }); }, 60_000); it.each([ diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 0cd228c0cf..63cd098206 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1,5 +1,5 @@ import { ensureNativeCompletionContract } from "../services/native-runtime/completion-contracts.js"; -import { readNativeCursorPlanWait, hasCommittedNativeCursorPlanWait } from "../services/native-runtime/native-cursor-plan-wait.js"; +import { readNativePlanWait, hasCommittedNativePlanWait } from "../services/native-runtime/native-plan-wait.js"; import { nativeSha256 } from "../services/native-runtime/canonical.js"; import { buildQuestionResponseDeliveryEnvelope } from "../services/question-response-delivery.js"; import * as executionContinuation from "../services/execution-continuation.js"; @@ -7930,8 +7930,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }); expect(next?.contextSnapshot?.wakeCommentIds).toEqual([pending!.id, go!.id]); expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, deferred!.id)))[0]).toMatchObject({ status: "coalesced", runId: next!.id }); - await heartbeat.drainActiveRunExecutions(); - expect((await heartbeat.getRun(next!.id))?.status).not.toBe("running"); + await vi.waitFor(async () => expect((await heartbeat.getRun(next!.id))?.status).not.toBe("running")); }); it.each(["dedicated deferred donor", "non-coalescing recipient", "persistent agent conversation"] as const)( @@ -14537,226 +14536,6 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(recoveryAction.ownerType).toBe("board"); }); - describe("accepted Cursor planning boundaries", () => { - const nativeImplementation = mockExecutePaperclipNativeSession.getMockImplementation(); - beforeEach(() => { - mockExecutePaperclipNativeSession.mockImplementation(async () => { throw new Error("Accepted-plan tests must never execute a provider"); }); - }); - afterEach(() => { mockExecutePaperclipNativeSession.mockImplementation(nativeImplementation!); }); - - async function seedAcceptedCursorPlanWait(semanticFinish = false, sourceSequenceOffset = 0) { - const f = await seedStrandedIssueFixture({ status: "in_progress", runStatus: "succeeded", livenessState: "advanced" }); - const instance = randomUUID(), interactionId = randomUUID(); - const contractInput = { db, companyId: f.companyId, - issue: { id: f.issueId, title: "Review the plan before further work", description: "Explicit completion required" }, actorId: "test" }; - const { row: persistedContract, contract } = await ensureNativeCompletionContract(contractInput); - const reused = await ensureNativeCompletionContract(contractInput); - expect(reused.row.id).toBe(persistedContract.id); - expect(reused.contract).toEqual(contract); - const contractId = persistedContract.id, contractSha = persistedContract.canonicalSha256; - // Production binds policy/schema as well as the body; a body-only hash is not a valid contract receipt. - expect(contractSha).not.toBe(nativeSha256(contract)); - const model = "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"; - await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "cursor", model, acpxSessionMode: "plan", acpxPermissionMode: "approve-all" } }).where(eq(agents.id, f.agentId)); - await db.update(agentWakeupRequests).set({ status: "completed" }).where(eq(agentWakeupRequests.id, f.wakeupRequestId)); - await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: f.issueId, nativeSessionId: f.runId, - completionContractId: contractId, completionContractSha256: contractSha, runnerInstanceId: instance, - runnerProfileJson: { nativeExecutionInput: { binding: { companyId: f.companyId, issueId: f.issueId, runId: f.runId, agentId: f.agentId }, provider: { kind: "acpx", agent: "cursor", model, cursorMode: "plan", permissionMode: "approve-all", - profile: paperclipRunner.resolveQualifiedAcpxProfile("cursor", model) }, session: { normalizedSessionId: f.runId }, - completionContract: { id: contractId, sha256: contractSha, contract } } } }).where(eq(heartbeatRuns.id, f.runId)); - const planId = `plan-${"a".repeat(64)}`; - const questionSet = { schema: "paperclip.question_set.v1", title: "Native plan", description: "Exact accepted revision", questions: [{ id: planId, prompt: "Proceed?", required: true, - answerMode: "single_select", options: [{ id: "accept", label: "Accept" }, { id: "reject", label: "Reject" }, { id: "cancel", label: "Cancel" }] }] }; - const [interaction] = await db.insert(issueThreadInteractions).values({ id: interactionId, companyId: f.companyId, issueId: f.issueId, sourceRunId: f.runId, - createdByAgentId: f.agentId, kind: "ask_user_questions", status: "answered", continuationPolicy: "none", - idempotencyKey: `paperclip-runner-question:${f.runId}:request`, resolvedByUserId: "responsible-user", resolvedAt: new Date(Date.now() - 1000), - payload: { version: 1, questions: [{ id: planId, prompt: "Proceed?", selectionMode: "single", required: true, allowOther: false, options: [{ id: "accept", label: "Accept" }, { id: "reject", label: "Reject" }, { id: "cancel", label: "Cancel" }] }], runtimeRequestId: "request", questionSet: questionSet as never }, result: { version: 1, answers: [{ questionId: planId, optionIds: ["accept"] }] } }).returning(); - const answer = buildQuestionResponseDeliveryEnvelope(interaction as never); - const [delivery] = await db.insert(issueQuestionResponseDeliveries).values({ companyId: f.companyId, issueId: f.issueId, interactionId, - sourceRunId: f.runId, targetRunId: f.runId, correlationId: randomUUID(), status: "delivered", deliveryMode: "steered", acknowledgedAt: new Date(), payloadSha256: nativeSha256(answer) }).returning(); - const port = new PaperclipControlPlanePort(db, { companyId: f.companyId, issueId: f.issueId, runId: f.runId, agentId: f.agentId, - sessionId: f.runId, completionContractId: contractId, completionContractSha256: contractSha, sourceInstanceId: instance, controlPlaneSourceInstanceId: `control-${f.runId}` }); - const events = [ - { eventType: "runtime_request.created", payload: { request: { schema: "paperclip.runtime_request.v2", requestKind: "runtime", requestId: "request", type: "input", status: "pending", turnId: "turn", itemId: "native-plan-tool", prompt: "Review plan", - origin: { adapter: "acpx-runtime-sidecar", provider: "cursor", method: "cursor/create_plan" }, input: questionSet } } }, - { eventType: "tool.execution.started", payload: { schema: "paperclip.tool.execution.v1", executionId: "native-plan-tool", transport: "builtin", operation: "execute", status: "running", readOnly: false, namespace: null, name: "Create Plan", target: null, inputUpdated: true, output: null, outputBytes: 0, outputTruncated: false, outputDigest: null, progress: null, exitCode: null, durationMs: null } }, - { eventType: "runtime_request.resolved", payload: { requestId: "request", turnId: "turn", action: "submit", response: answer.response } }, - { eventType: "tool.execution.completed", payload: { schema: "paperclip.tool.execution.v1", executionId: "native-plan-tool", transport: "builtin", operation: "execute", status: "completed", readOnly: false, namespace: null, name: "Create Plan", target: null, inputUpdated: false, output: null, outputBytes: 0, outputTruncated: false, outputDigest: null, progress: null, exitCode: null, durationMs: null } }, - { eventType: "turn.completed", payload: { status: "completed", error: null } }, - ]; - for (const [index, event] of events.entries()) await port.appendEvent({ schema: "paperclip.prp.event.v1", sourceEventId: `${instance}:${sourceSequenceOffset + index + 1}`, sourceSeq: sourceSequenceOffset + index + 1, - sourceInstanceId: instance, sourceKind: "runner", runId: f.runId, normalizedSessionId: f.runId, turnId: "turn", schemaVersion: 1, priority: 0, - emittedAt: new Date().toISOString(), ...event } as paperclipRunner.PrpEvent); - const proof = await readNativeCursorPlanWait(db, f); - expect(proof).not.toBeNull(); - const result = semanticFinish ? { ...proof!.result, reportedWorkDisposition: "done" as const, summary: "Explicit semantic finish wins", continuation: undefined, - completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [{ criterionId: "objective", status: "satisfied" as const, evidenceRefs: [] }], remainingWork: [] } } : proof!.result; - await port.completeRun({ result, turnId: "turn", terminal: { schema: "paperclip.prp.terminal.v1", runTerminalState: "succeeded", turnTerminalState: "completed", reportedWorkDisposition: result.reportedWorkDisposition } }); - return { ...f, interactionId, deliveryId: delivery!.id, proof: proof! }; - } - - it("keeps an accepted Cursor plan passive across restart, future configuration changes, and paused recovery", async () => { - const f = await seedAcceptedCursorPlanWait(); - await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true }); - await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true }); - expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(true); - expect(await db.select().from(statusDecisions).where(eq(statusDecisions.runId, f.runId))).toEqual([expect.objectContaining({ reasonCode: "native_plan_accepted_waiting_for_continuation", toStatus: "in_progress" })]); - expect(await db.select().from(statusDecisionEffects).where(eq(statusDecisionEffects.issueId, f.issueId))).toEqual([expect.objectContaining({ effectKind: "issue_status_projection", targetType: "issue", deliveryState: "delivered" })]); - expect(await db.select().from(issueComments).where(eq(issueComments.createdByRunId, f.runId))).toEqual([expect.objectContaining({ body: expect.stringContaining("next message") })]); - const current = paperclipRunner.resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"); - const resolver = vi.spyOn(paperclipRunner, "resolveQualifiedAcpxProfile").mockReturnValue({ ...current, - agentProfileVersion: current.agentProfileVersion + 1, commandDigest: `sha256:${"b".repeat(64)}` }); - try { - expect(await readNativeCursorPlanWait(db, f)).toBeNull(); // Old profile cannot create a new wait. - for (const status of ["idle", "paused"] as const) { - await db.update(agents).set({ status, adapterConfig: { provider: "acpx", acpxAgent: "cursor", - model: "future-model", acpxSessionMode: "agent", acpxPermissionMode: "approve-reads" } }).where(eq(agents.id, f.agentId)); - expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(true); - const recovered = await heartbeatService(db).reconcileStrandedAssignedIssues(); - expect(recovered.continuationRequeued).toBe(0); expect(recovered.escalated).toBe(0); - } - const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); - const changed = structuredClone(run!.runnerProfileJson!); - (changed.nativeExecutionInput as { provider: { profile: { commandDigest: string } } }).provider.profile.commandDigest = "tampered-original-profile"; - await db.update(heartbeatRuns).set({ runnerProfileJson: changed }).where(eq(heartbeatRuns.id, f.runId)); - expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(false); - await db.update(heartbeatRuns).set({ runnerProfileJson: run!.runnerProfileJson }).where(eq(heartbeatRuns.id, f.runId)); - expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(true); - const [tool] = await db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.runId, f.runId), eq(heartbeatRunEvents.eventType, "tool.execution.completed"))); - const changedTool = structuredClone(tool!.payload!) as { prpEvent: { payload: { name: string } } }; - changedTool.prpEvent.payload.name = "mutated committed tool evidence"; - await db.update(heartbeatRunEvents).set({ payload: changedTool, sourcePayloadSha256: nativeSha256(changedTool.prpEvent) }).where(eq(heartbeatRunEvents.id, tool!.id)); - expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(false); - await db.update(heartbeatRunEvents).set({ payload: tool!.payload, sourcePayloadSha256: tool!.sourcePayloadSha256 }).where(eq(heartbeatRunEvents.id, tool!.id)); - expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(true); - } finally { resolver.mockRestore(); } - expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(1); - expect(mockAdapterExecute).not.toHaveBeenCalled(); expect(mockExecutePaperclipNativeSession).not.toHaveBeenCalled(); - }); - - it("retains an exact historical Cursor6 committed wait across more than 1000 ignored progress rows without allowing new unbound admission", async () => { - const progressCount = 1002; - const f = await seedAcceptedCursorPlanWait(false, progressCount); - await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true }); - const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); - const profile = structuredClone(run!.runnerProfileJson!) as any; - Object.assign(profile.nativeExecutionInput.provider.profile, { agentProfileVersion: 6, commandDigest: "sha256:377dcea64a727ce799cc112458d4b40ba4bc6574cd6c6f7233b6efd5917a6c4b" }); - await db.update(heartbeatRuns).set({ runnerProfileJson: profile }).where(eq(heartbeatRuns.id, f.runId)); - await db.delete(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.runId, f.runId), inArray(heartbeatRunEvents.eventType, ["tool.execution.started", "tool.execution.completed"]))); - // Reconstruct the persisted Cursor6 receipt format using its unchanged - // source facts. This historical format had no tool lifecycle binding. - const rows = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, f.runId)); - const event = (kind: string) => (rows.find(r => r.eventType === kind)!.payload as any).prpEvent; - const [contract] = await db.select().from(completionContracts).where(eq(completionContracts.id, run!.completionContractId!)); - const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, f.interactionId)); - const [delivery] = await db.select().from(issueQuestionResponseDeliveries).where(eq(issueQuestionResponseDeliveries.id, f.deliveryId)); - const legacy = { ...f.proof.source }; delete legacy.toolExecutionId; delete legacy.toolLifecycleSha256; - legacy.authoritySha256 = nativeSha256({ admission: profile.nativeExecutionInput, contract, created: event("runtime_request.created"), resolved: event("runtime_request.resolved"), terminal: event("turn.completed"), interaction, delivery, resolvedAt: interaction!.resolvedAt!.toISOString(), acknowledgedAt: delivery!.acknowledgedAt!.toISOString() }); - const [decision] = await db.select().from(statusDecisions).where(eq(statusDecisions.runId, f.runId)); - await db.update(statusDecisions).set({ decisionJson: { ...decision!.decisionJson, cursorPlanWait: legacy } }).where(eq(statusDecisions.id, decision!.id)); - expect(await readNativeCursorPlanWait(db, f)).toBeNull(); - expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(true); - // Fill the reserved source prefix with genuine durable progress rows. Shift - // only DB ordering keys; the original PRP facts and committed receipt stay - // byte-identical. Cursor6 never queried these rows, even above its budget. - await db.update(heartbeatRunEvents).set({ seq: sql`${heartbeatRunEvents.seq} + ${progressCount}` }).where(eq(heartbeatRunEvents.runId, f.runId)); - const progress = Array.from({ length: progressCount }, (_, index) => { - const seq = index + 1; - const prpEvent = { ...event("runtime_request.created"), sourceEventId: `${run!.runnerInstanceId}:${seq}`, sourceSeq: seq, eventType: "tool.execution.progressed", - payload: { schema: "paperclip.tool.execution.v1", executionId: "earlier-tool", transport: "builtin", operation: "read", status: "running" } }; - return { companyId: f.companyId, runId: f.runId, agentId: f.agentId, seq, eventType: prpEvent.eventType, payload: { prpEvent }, sourceInstanceId: run!.runnerInstanceId, - sourceEventId: prpEvent.sourceEventId, sourceSeq: seq, sourcePayloadSha256: nativeSha256(prpEvent), protocolSchemaVersion: 1 }; - }); - await db.insert(heartbeatRunEvents).values(progress); - expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(true); - const [unchangedDecision] = await db.select().from(statusDecisions).where(eq(statusDecisions.id, decision!.id)); - expect(unchangedDecision!.decisionJson!.cursorPlanWait).toEqual(legacy); - // Completed rows belonged to the old query. A later completion must still - // invalidate the normal-terminal boundary rather than being filtered away. - const completion = { ...event("turn.completed"), sourceEventId: `${run!.runnerInstanceId}:99999`, sourceSeq: 99999, eventType: "tool.execution.completed", payload: { ...progress[0]!.payload.prpEvent.payload, status: "completed" } }; - const [extra] = await db.insert(heartbeatRunEvents).values({ ...progress[0]!, seq: 99999, eventType: completion.eventType, payload: { prpEvent: completion }, sourceEventId: completion.sourceEventId, sourceSeq: 99999, sourcePayloadSha256: nativeSha256(completion) }).returning(); - expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(false); - await db.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.id, extra!.id)); - expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(true); - profile.nativeExecutionInput.provider.profile.commandDigest = "tampered-history"; - await db.update(heartbeatRuns).set({ runnerProfileJson: profile }).where(eq(heartbeatRuns.id, f.runId)); - expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(false); - }); - - it("admits a later ordinary user message after an accepted Cursor plan without changing Plan mode or replaying its run", async () => { - const f = await seedAcceptedCursorPlanWait(); - await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true }); - // Occupy the single dispatch slot: this checks actual user-wake admission - // without executing any provider or fabricating a second semantic result. - const occupied = randomUUID(); - await db.update(agents).set({ runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } }).where(eq(agents.id, f.agentId)); - await db.insert(heartbeatRuns).values({ id: occupied, companyId: f.companyId, agentId: f.agentId, status: "running", startedAt: new Date(), contextSnapshot: {} }); - const [comment] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorType: "user", authorUserId: "responsible-user", body: "Continue reviewing the accepted plan in Plan mode." }).returning(); - try { - const heartbeat = heartbeatService(db); - const next = await heartbeat.wakeup(f.agentId, { source: "automation", triggerDetail: "system", reason: "issue_commented", requestedByActorType: "user", requestedByActorId: "responsible-user", - payload: { issueId: f.issueId, commentId: comment!.id }, contextSnapshot: { issueId: f.issueId, taskId: f.issueId, wakeCommentId: comment!.id, wakeCommentIds: [comment!.id] } }); - expect(next).not.toBeNull(); expect(next!.id).not.toBe(f.runId); - const [admitted] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, next!.id)); - expect(admitted).toMatchObject({ status: "queued", retryOfRunId: null }); - expect(admitted!.contextSnapshot?.wakeCommentIds).toContain(comment!.id); - const [agent] = await db.select().from(agents).where(eq(agents.id, f.agentId)); - expect(agent!.adapterConfig.acpxSessionMode).toBe("plan"); - expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(false); - expect(mockExecutePaperclipNativeSession).not.toHaveBeenCalled(); - } finally { - await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(and(eq(heartbeatRuns.companyId, f.companyId), inArray(heartbeatRuns.status, ["running", "queued"]))); - await db.update(agentWakeupRequests).set({ status: "cancelled", finishedAt: new Date() }).where(and(eq(agentWakeupRequests.companyId, f.companyId), inArray(agentWakeupRequests.status, ["queued", "claimed"]))); - } - }); - - it.each(["delivery", "assignment", "admission", "user_request", "result_or_decision"] as const)("revokes an accepted Cursor plan passive wait after %s changes", async change => { - const f = await seedAcceptedCursorPlanWait(); - await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true }); - if (change === "delivery") await db.update(issueQuestionResponseDeliveries).set({ acknowledgedAt: null }).where(eq(issueQuestionResponseDeliveries.id, f.deliveryId)); - if (change === "assignment") await db.update(issues).set({ assigneeAgentId: null }).where(eq(issues.id, f.issueId)); - if (change === "admission") { - const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); - const profile = structuredClone(run!.runnerProfileJson!); - (profile.nativeExecutionInput as { provider: { cursorMode: string } }).provider.cursorMode = "agent"; - await db.update(heartbeatRuns).set({ runnerProfileJson: profile }).where(eq(heartbeatRuns.id, f.runId)); - } - if (change === "user_request") await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorType: "user", authorUserId: "responsible-user", body: "Continue reviewing this plan in Plan mode." }); - if (change === "result_or_decision") { - const [accepted] = await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, f.runId)); - await db.update(nativeRunResults).set({ canonicalSha256: "changed" }).where(eq(nativeRunResults.id, accepted!.id)); - expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(false); - await db.update(nativeRunResults).set({ canonicalSha256: accepted!.canonicalSha256 }).where(eq(nativeRunResults.id, accepted!.id)); - expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(true); - await db.update(issues).set({ lastStatusDecisionId: null }).where(eq(issues.id, f.issueId)); - } - expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(false); - }); - - it("rechecks accepted Cursor plan delivery under the status transaction before presentation or effects", async () => { - const f = await seedAcceptedCursorPlanWait(); - await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", failpoint: "status_projection" }); - const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)); - const [issue] = await db.select().from(issues).where(eq(issues.id, f.issueId)); - await db.update(issueQuestionResponseDeliveries).set({ payloadSha256: "changed" }).where(eq(issueQuestionResponseDeliveries.id, f.deliveryId)); - await expect(commitNativeStatusDecision({ db, companyId: f.companyId, issueId: f.issueId, runId: f.runId, assessmentId: coordinator!.assessmentId!, - priorStatus: issue!.status, priorStatusVersion: issue!.statusVersion, priorDecisionId: issue!.lastStatusDecisionId, - decision: { policyVersion: "phase6-v7", statusAction: "in_progress", toStatus: "in_progress", reasonCode: "native_plan_accepted_waiting_for_continuation", unblockDescriptor: null, effects: [] }, - requireCursorPlanWaitSource: f.proof.source })).rejects.toBeInstanceOf(NativeStatusRaceError); - expect(await db.select().from(statusDecisions).where(eq(statusDecisions.runId, f.runId))).toHaveLength(0); - expect(await db.select().from(issueComments).where(eq(issueComments.createdByRunId, f.runId))).toHaveLength(0); - }); - - it("preserves explicit semantic finish priority over accepted Cursor plan evidence", async () => { - const f = await seedAcceptedCursorPlanWait(true); - await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true }); - expect(await hasCommittedNativeCursorPlanWait(db, f)).toBe(false); - const decisions = await db.select().from(statusDecisions).where(eq(statusDecisions.runId, f.runId)); - expect(decisions).toHaveLength(1); expect(decisions[0]!.reasonCode).not.toBe("native_plan_accepted_waiting_for_continuation"); - }); - - }); - async function seedNativePassiveBoardResponse( continuationKind: "response_wake" | "same_agent" | "retry" = "response_wake", @@ -16627,4 +16406,237 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { .where(eq(heartbeatRuns.id, runId)); expect(runs).toHaveLength(1); }); + describe("accepted Cursor planning boundaries", () => { + const nativeImplementation = mockExecutePaperclipNativeSession.getMockImplementation(); + beforeEach(() => { + mockExecutePaperclipNativeSession.mockImplementation(async () => { throw new Error("Accepted-plan tests must never execute a provider"); }); + }); + afterEach(() => { mockExecutePaperclipNativeSession.mockImplementation(nativeImplementation!); }); + + async function seedAcceptedCursorPlanWait(semanticFinish = false, sourceSequenceOffset = 0) { + const f = await seedStrandedIssueFixture({ status: "in_progress", runStatus: "succeeded", livenessState: "advanced" }); + const instance = randomUUID(), interactionId = randomUUID(); + const contractInput = { db, companyId: f.companyId, + issue: { id: f.issueId, title: "Review the plan before further work", description: "Explicit completion required" }, actorId: "test" }; + const { row: persistedContract, contract } = await ensureNativeCompletionContract(contractInput); + const reused = await ensureNativeCompletionContract(contractInput); + expect(reused.row.id).toBe(persistedContract.id); + expect(reused.contract).toEqual(contract); + const contractId = persistedContract.id, contractSha = persistedContract.canonicalSha256; + // Production binds policy/schema as well as the body; a body-only hash is not a valid contract receipt. + expect(contractSha).not.toBe(nativeSha256(contract)); + const model = "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"; + await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "cursor", model, acpxSessionMode: "plan", acpxPermissionMode: "approve-all" } }).where(eq(agents.id, f.agentId)); + await db.update(agentWakeupRequests).set({ status: "completed" }).where(eq(agentWakeupRequests.id, f.wakeupRequestId)); + await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: f.issueId, nativeSessionId: f.runId, + completionContractId: contractId, completionContractSha256: contractSha, runnerInstanceId: instance, + runnerProfileJson: { nativeExecutionInput: { binding: { companyId: f.companyId, issueId: f.issueId, runId: f.runId, agentId: f.agentId }, provider: { kind: "acpx", agent: "cursor", model, mode: "plan", permissionMode: "approve-all", + profile: paperclipRunner.resolveQualifiedAcpxProfile("cursor", model) }, session: { normalizedSessionId: f.runId }, + completionContract: { id: contractId, sha256: contractSha, contract } } } }).where(eq(heartbeatRuns.id, f.runId)); + const planId = `plan-${"a".repeat(64)}`; + const questionSet = { schema: "paperclip.question_set.v1", title: "Native plan", description: "Exact accepted revision", questions: [{ id: planId, prompt: "Proceed?", required: true, + answerMode: "single_select", options: [{ id: "accept", label: "Accept" }, { id: "reject", label: "Reject" }, { id: "cancel", label: "Cancel" }] }] }; + const [interaction] = await db.insert(issueThreadInteractions).values({ id: interactionId, companyId: f.companyId, issueId: f.issueId, sourceRunId: f.runId, + createdByAgentId: f.agentId, kind: "ask_user_questions", status: "answered", continuationPolicy: "none", + idempotencyKey: `paperclip-runner-question:${f.runId}:request`, resolvedByUserId: "responsible-user", resolvedAt: new Date(Date.now() - 1000), + payload: { version: 1, questions: [{ id: planId, prompt: "Proceed?", selectionMode: "single", required: true, allowOther: false, options: [{ id: "accept", label: "Accept" }, { id: "reject", label: "Reject" }, { id: "cancel", label: "Cancel" }] }], runtimeRequestId: "request", questionSet: questionSet as never }, result: { version: 1, answers: [{ questionId: planId, optionIds: ["accept"] }] } }).returning(); + const answer = buildQuestionResponseDeliveryEnvelope(interaction as never); + const [delivery] = await db.insert(issueQuestionResponseDeliveries).values({ companyId: f.companyId, issueId: f.issueId, interactionId, + sourceRunId: f.runId, targetRunId: f.runId, correlationId: randomUUID(), status: "delivered", deliveryMode: "steered", acknowledgedAt: new Date(), payloadSha256: nativeSha256(answer) }).returning(); + const port = new PaperclipControlPlanePort(db, { companyId: f.companyId, issueId: f.issueId, runId: f.runId, agentId: f.agentId, + sessionId: f.runId, completionContractId: contractId, completionContractSha256: contractSha, sourceInstanceId: instance, controlPlaneSourceInstanceId: `control-${f.runId}` }); + const events = [ + { eventType: "runtime_request.created", payload: { request: { schema: "paperclip.runtime_request.v2", requestKind: "runtime", requestId: "request", type: "input", status: "pending", turnId: "turn", itemId: "native-plan-tool", prompt: "Review plan", + origin: { adapter: "acpx-runtime-sidecar", provider: "cursor", method: "cursor/create_plan" }, input: questionSet } } }, + { eventType: "tool.execution.started", payload: { schema: "paperclip.tool.execution.v1", executionId: "native-plan-tool", transport: "builtin", operation: "execute", status: "running", readOnly: false, namespace: null, name: "Create Plan", target: null, inputUpdated: true, output: null, outputBytes: 0, outputTruncated: false, outputDigest: null, progress: null, exitCode: null, durationMs: null } }, + { eventType: "runtime_request.resolved", payload: { requestId: "request", turnId: "turn", action: "submit", response: answer.response } }, + { eventType: "tool.execution.completed", payload: { schema: "paperclip.tool.execution.v1", executionId: "native-plan-tool", transport: "builtin", operation: "execute", status: "completed", readOnly: false, namespace: null, name: "Create Plan", target: null, inputUpdated: false, output: null, outputBytes: 0, outputTruncated: false, outputDigest: null, progress: null, exitCode: null, durationMs: null } }, + { eventType: "turn.completed", payload: { status: "completed", error: null } }, + ]; + for (const [index, event] of events.entries()) await port.appendEvent({ schema: "paperclip.prp.event.v1", sourceEventId: `${instance}:${sourceSequenceOffset + index + 1}`, sourceSeq: sourceSequenceOffset + index + 1, + sourceInstanceId: instance, sourceKind: "runner", runId: f.runId, normalizedSessionId: f.runId, turnId: "turn", schemaVersion: 1, priority: 0, + emittedAt: new Date().toISOString(), ...event } as paperclipRunner.PrpEvent); + const proof = await readNativePlanWait(db, f); + expect(proof).not.toBeNull(); + const result = semanticFinish ? { ...proof!.result, reportedWorkDisposition: "done" as const, summary: "Explicit semantic finish wins", continuation: undefined, + completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [{ criterionId: "objective", status: "satisfied" as const, evidenceRefs: [] }], remainingWork: [] } } : proof!.result; + await port.completeRun({ result, turnId: "turn", terminal: { schema: "paperclip.prp.terminal.v1", runTerminalState: "succeeded", turnTerminalState: "completed", reportedWorkDisposition: result.reportedWorkDisposition } }); + return { ...f, interactionId, deliveryId: delivery!.id, proof: proof! }; + } + + it("keeps an accepted Cursor plan passive across restart, future configuration changes, and paused recovery", async () => { + const f = await seedAcceptedCursorPlanWait(); + await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true }); + await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true }); + expect(await hasCommittedNativePlanWait(db, f)).toBe(true); + expect(await db.select().from(statusDecisions).where(eq(statusDecisions.runId, f.runId))).toEqual([expect.objectContaining({ reasonCode: "native_plan_accepted_waiting_for_continuation", toStatus: "in_progress" })]); + expect(await db.select().from(statusDecisionEffects).where(eq(statusDecisionEffects.issueId, f.issueId))).toEqual([expect.objectContaining({ effectKind: "issue_status_projection", targetType: "issue", deliveryState: "delivered" })]); + expect(await db.select().from(issueComments).where(eq(issueComments.createdByRunId, f.runId))).toEqual([expect.objectContaining({ body: expect.stringContaining("next message") })]); + const current = paperclipRunner.resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"); + const resolver = vi.spyOn(paperclipRunner, "resolveQualifiedAcpxProfile").mockReturnValue({ ...current, + agentProfileVersion: current.agentProfileVersion + 1, commandDigest: `sha256:${"b".repeat(64)}` }); + try { + expect(await readNativePlanWait(db, f)).toBeNull(); // Old profile cannot create a new wait. + for (const status of ["idle", "paused"] as const) { + await db.update(agents).set({ status, adapterConfig: { provider: "acpx", acpxAgent: "cursor", + model: "future-model", acpxSessionMode: "agent", acpxPermissionMode: "approve-reads" } }).where(eq(agents.id, f.agentId)); + expect(await hasCommittedNativePlanWait(db, f)).toBe(true); + const recovered = await heartbeatService(db).reconcileStrandedAssignedIssues(); + expect(recovered.continuationRequeued).toBe(0); expect(recovered.escalated).toBe(0); + } + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + const changed = structuredClone(run!.runnerProfileJson!); + (changed.nativeExecutionInput as { provider: { profile: { commandDigest: string } } }).provider.profile.commandDigest = "tampered-original-profile"; + await db.update(heartbeatRuns).set({ runnerProfileJson: changed }).where(eq(heartbeatRuns.id, f.runId)); + expect(await hasCommittedNativePlanWait(db, f)).toBe(false); + await db.update(heartbeatRuns).set({ runnerProfileJson: run!.runnerProfileJson }).where(eq(heartbeatRuns.id, f.runId)); + expect(await hasCommittedNativePlanWait(db, f)).toBe(true); + const [tool] = await db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.runId, f.runId), eq(heartbeatRunEvents.eventType, "tool.execution.completed"))); + const changedTool = structuredClone(tool!.payload!) as { prpEvent: { payload: { name: string } } }; + changedTool.prpEvent.payload.name = "mutated committed tool evidence"; + await db.update(heartbeatRunEvents).set({ payload: changedTool, sourcePayloadSha256: nativeSha256(changedTool.prpEvent) }).where(eq(heartbeatRunEvents.id, tool!.id)); + expect(await hasCommittedNativePlanWait(db, f)).toBe(false); + await db.update(heartbeatRunEvents).set({ payload: tool!.payload, sourcePayloadSha256: tool!.sourcePayloadSha256 }).where(eq(heartbeatRunEvents.id, tool!.id)); + expect(await hasCommittedNativePlanWait(db, f)).toBe(true); + } finally { resolver.mockRestore(); } + expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(1); + expect(mockAdapterExecute).not.toHaveBeenCalled(); expect(mockExecutePaperclipNativeSession).not.toHaveBeenCalled(); + }); + + it("retains an exact historical Cursor6 committed wait across more than 1000 ignored progress rows without allowing new unbound admission", async () => { + const progressCount = 1002; + const f = await seedAcceptedCursorPlanWait(false, progressCount); + await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true }); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + const profile = structuredClone(run!.runnerProfileJson!) as any; + Object.assign(profile.nativeExecutionInput.provider.profile, { agentProfileVersion: 6, commandDigest: "sha256:377dcea64a727ce799cc112458d4b40ba4bc6574cd6c6f7233b6efd5917a6c4b" }); + await db.update(heartbeatRuns).set({ runnerProfileJson: profile }).where(eq(heartbeatRuns.id, f.runId)); + await db.delete(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.runId, f.runId), inArray(heartbeatRunEvents.eventType, ["tool.execution.started", "tool.execution.completed"]))); + // Reconstruct the persisted Cursor6 receipt format using its unchanged + // source facts. This historical format had no tool lifecycle binding. + const rows = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, f.runId)); + const event = (kind: string) => (rows.find(r => r.eventType === kind)!.payload as any).prpEvent; + const [contract] = await db.select().from(completionContracts).where(eq(completionContracts.id, run!.completionContractId!)); + const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, f.interactionId)); + const [delivery] = await db.select().from(issueQuestionResponseDeliveries).where(eq(issueQuestionResponseDeliveries.id, f.deliveryId)); + profile.nativeExecutionInput.provider.cursorMode = profile.nativeExecutionInput.provider.mode; + delete profile.nativeExecutionInput.provider.mode; + await db.update(heartbeatRuns).set({ runnerProfileJson: profile }).where(eq(heartbeatRuns.id, f.runId)); + const legacy = { ...f.proof.source, schema: "paperclip.native_cursor_plan_wait.v1" }; delete legacy.toolExecutionId; delete legacy.toolLifecycleSha256; + legacy.authoritySha256 = nativeSha256({ admission: profile.nativeExecutionInput, contract, created: event("runtime_request.created"), resolved: event("runtime_request.resolved"), terminal: event("turn.completed"), interaction, delivery, resolvedAt: interaction!.resolvedAt!.toISOString(), acknowledgedAt: delivery!.acknowledgedAt!.toISOString() }); + const [decision] = await db.select().from(statusDecisions).where(eq(statusDecisions.runId, f.runId)); + const [savedResult] = await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, f.runId)); + const resultJson = structuredClone(savedResult!.resultJson!) as any; + resultJson.result.continuation.idempotencyKey = resultJson.result.continuation.idempotencyKey.replace("native-plan-wait:", "cursor-plan-wait:"); + const canonicalSha256 = nativeSha256(resultJson); + await db.update(nativeRunResults).set({ resultJson, canonicalSha256 }).where(eq(nativeRunResults.id, savedResult!.id)); + const decisionJson = { ...decision!.decisionJson } as any; + delete decisionJson.planWait; delete decisionJson.planWaitResult; + decisionJson.cursorPlanWait = legacy; + decisionJson.cursorPlanWaitResult = { resultId: savedResult!.id, resultSha256: canonicalSha256 }; + await db.update(statusDecisions).set({ decisionJson }).where(eq(statusDecisions.id, decision!.id)); + expect(await readNativePlanWait(db, f)).toBeNull(); + expect(await hasCommittedNativePlanWait(db, f)).toBe(true); + // Fill the reserved source prefix with genuine durable progress rows. Shift + // only DB ordering keys; the original PRP facts and committed receipt stay + // byte-identical. Cursor6 never queried these rows, even above its budget. + await db.update(heartbeatRunEvents).set({ seq: sql`${heartbeatRunEvents.seq} + ${progressCount}` }).where(eq(heartbeatRunEvents.runId, f.runId)); + const progress = Array.from({ length: progressCount }, (_, index) => { + const seq = index + 1; + const prpEvent = { ...event("runtime_request.created"), sourceEventId: `${run!.runnerInstanceId}:${seq}`, sourceSeq: seq, eventType: "tool.execution.progressed", + payload: { schema: "paperclip.tool.execution.v1", executionId: "earlier-tool", transport: "builtin", operation: "read", status: "running" } }; + return { companyId: f.companyId, runId: f.runId, agentId: f.agentId, seq, eventType: prpEvent.eventType, payload: { prpEvent }, sourceInstanceId: run!.runnerInstanceId, + sourceEventId: prpEvent.sourceEventId, sourceSeq: seq, sourcePayloadSha256: nativeSha256(prpEvent), protocolSchemaVersion: 1 }; + }); + await db.insert(heartbeatRunEvents).values(progress); + expect(await hasCommittedNativePlanWait(db, f)).toBe(true); + const [unchangedDecision] = await db.select().from(statusDecisions).where(eq(statusDecisions.id, decision!.id)); + expect(unchangedDecision!.decisionJson!.cursorPlanWait).toEqual(legacy); + // Completed rows belonged to the old query. A later completion must still + // invalidate the normal-terminal boundary rather than being filtered away. + const completion = { ...event("turn.completed"), sourceEventId: `${run!.runnerInstanceId}:99999`, sourceSeq: 99999, eventType: "tool.execution.completed", payload: { ...progress[0]!.payload.prpEvent.payload, status: "completed" } }; + const [extra] = await db.insert(heartbeatRunEvents).values({ ...progress[0]!, seq: 99999, eventType: completion.eventType, payload: { prpEvent: completion }, sourceEventId: completion.sourceEventId, sourceSeq: 99999, sourcePayloadSha256: nativeSha256(completion) }).returning(); + expect(await hasCommittedNativePlanWait(db, f)).toBe(false); + await db.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.id, extra!.id)); + expect(await hasCommittedNativePlanWait(db, f)).toBe(true); + profile.nativeExecutionInput.provider.profile.commandDigest = "tampered-history"; + await db.update(heartbeatRuns).set({ runnerProfileJson: profile }).where(eq(heartbeatRuns.id, f.runId)); + expect(await hasCommittedNativePlanWait(db, f)).toBe(false); + }); + + it("admits a later ordinary user message after an accepted Cursor plan without changing Plan mode or replaying its run", async () => { + const f = await seedAcceptedCursorPlanWait(); + await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true }); + // Occupy the single dispatch slot: this checks actual user-wake admission + // without executing any provider or fabricating a second semantic result. + const occupied = randomUUID(); + await db.update(agents).set({ runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } }).where(eq(agents.id, f.agentId)); + await db.insert(heartbeatRuns).values({ id: occupied, companyId: f.companyId, agentId: f.agentId, status: "running", startedAt: new Date(), contextSnapshot: {} }); + const [comment] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorType: "user", authorUserId: "responsible-user", body: "Continue reviewing the accepted plan in Plan mode." }).returning(); + try { + const heartbeat = heartbeatService(db); + const next = await heartbeat.wakeup(f.agentId, { source: "automation", triggerDetail: "system", reason: "issue_commented", requestedByActorType: "user", requestedByActorId: "responsible-user", + payload: { issueId: f.issueId, commentId: comment!.id }, contextSnapshot: { issueId: f.issueId, taskId: f.issueId, wakeCommentId: comment!.id, wakeCommentIds: [comment!.id] } }); + expect(next).not.toBeNull(); expect(next!.id).not.toBe(f.runId); + const [admitted] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, next!.id)); + expect(admitted).toMatchObject({ status: "queued", retryOfRunId: null }); + expect(admitted!.contextSnapshot?.wakeCommentIds).toContain(comment!.id); + const [agent] = await db.select().from(agents).where(eq(agents.id, f.agentId)); + expect(agent!.adapterConfig.acpxSessionMode).toBe("plan"); + expect(await hasCommittedNativePlanWait(db, f)).toBe(false); + expect(mockExecutePaperclipNativeSession).not.toHaveBeenCalled(); + } finally { + await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(and(eq(heartbeatRuns.companyId, f.companyId), inArray(heartbeatRuns.status, ["running", "queued"]))); + await db.update(agentWakeupRequests).set({ status: "cancelled", finishedAt: new Date() }).where(and(eq(agentWakeupRequests.companyId, f.companyId), inArray(agentWakeupRequests.status, ["queued", "claimed"]))); + } + }); + + it.each(["delivery", "assignment", "admission", "user_request", "result_or_decision"] as const)("revokes an accepted Cursor plan passive wait after %s changes", async change => { + const f = await seedAcceptedCursorPlanWait(); + await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true }); + if (change === "delivery") await db.update(issueQuestionResponseDeliveries).set({ acknowledgedAt: null }).where(eq(issueQuestionResponseDeliveries.id, f.deliveryId)); + if (change === "assignment") await db.update(issues).set({ assigneeAgentId: null }).where(eq(issues.id, f.issueId)); + if (change === "admission") { + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + const profile = structuredClone(run!.runnerProfileJson!); + (profile.nativeExecutionInput as { provider: { cursorMode: string } }).provider.cursorMode = "agent"; + await db.update(heartbeatRuns).set({ runnerProfileJson: profile }).where(eq(heartbeatRuns.id, f.runId)); + } + if (change === "user_request") await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorType: "user", authorUserId: "responsible-user", body: "Continue reviewing this plan in Plan mode." }); + if (change === "result_or_decision") { + const [accepted] = await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, f.runId)); + await db.update(nativeRunResults).set({ canonicalSha256: "changed" }).where(eq(nativeRunResults.id, accepted!.id)); + expect(await hasCommittedNativePlanWait(db, f)).toBe(false); + await db.update(nativeRunResults).set({ canonicalSha256: accepted!.canonicalSha256 }).where(eq(nativeRunResults.id, accepted!.id)); + expect(await hasCommittedNativePlanWait(db, f)).toBe(true); + await db.update(issues).set({ lastStatusDecisionId: null }).where(eq(issues.id, f.issueId)); + } + expect(await hasCommittedNativePlanWait(db, f)).toBe(false); + }); + + it("rechecks accepted Cursor plan delivery under the status transaction before presentation or effects", async () => { + const f = await seedAcceptedCursorPlanWait(); + await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", failpoint: "status_projection" }); + const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)); + const [issue] = await db.select().from(issues).where(eq(issues.id, f.issueId)); + await db.update(issueQuestionResponseDeliveries).set({ payloadSha256: "changed" }).where(eq(issueQuestionResponseDeliveries.id, f.deliveryId)); + await expect(commitNativeStatusDecision({ db, companyId: f.companyId, issueId: f.issueId, runId: f.runId, assessmentId: coordinator!.assessmentId!, + priorStatus: issue!.status, priorStatusVersion: issue!.statusVersion, priorDecisionId: issue!.lastStatusDecisionId, + decision: { policyVersion: "phase6-v7", statusAction: "in_progress", toStatus: "in_progress", reasonCode: "native_plan_accepted_waiting_for_continuation", unblockDescriptor: null, effects: [] }, + requirePlanWaitSource: f.proof.source })).rejects.toBeInstanceOf(NativeStatusRaceError); + expect(await db.select().from(statusDecisions).where(eq(statusDecisions.runId, f.runId))).toHaveLength(0); + expect(await db.select().from(issueComments).where(eq(issueComments.createdByRunId, f.runId))).toHaveLength(0); + }); + + it("preserves explicit semantic finish priority over accepted Cursor plan evidence", async () => { + const f = await seedAcceptedCursorPlanWait(true); + await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true }); + expect(await hasCommittedNativePlanWait(db, f)).toBe(false); + const decisions = await db.select().from(statusDecisions).where(eq(statusDecisions.runId, f.runId)); + expect(decisions).toHaveLength(1); expect(decisions[0]!.reasonCode).not.toBe("native_plan_accepted_waiting_for_continuation"); + }); + + }); + + }); diff --git a/server/src/__tests__/invite-test-resolution-route.test.ts b/server/src/__tests__/invite-test-resolution-route.test.ts index ee3a9b9c10..38120e0d08 100644 --- a/server/src/__tests__/invite-test-resolution-route.test.ts +++ b/server/src/__tests__/invite-test-resolution-route.test.ts @@ -78,7 +78,12 @@ async function createApp( return app; } -describe("GET /invites/:token/test-resolution", () => { +describe("GET /invites/:token/test-resolution", { sequential: true }, () => { + beforeAll(async () => { + // Route transformation is fixture setup, not part of the network assertions. + await loadAppModules(); + }); + beforeEach(() => { vi.clearAllMocks(); }); diff --git a/server/src/__tests__/redaction.test.ts b/server/src/__tests__/redaction.test.ts index b4f392babc..1075c05ea1 100644 --- a/server/src/__tests__/redaction.test.ts +++ b/server/src/__tests__/redaction.test.ts @@ -41,20 +41,48 @@ function receiptNotice(version: 1 | 2 = 1): Record { const receiptSchemaValue = (notice: Record) => notice.details.find((detail: any) => detail.name === "schema").value; describe("redaction", () => { - it("preserves credential-related prose, metadata, and dotted filenames", () => { - const input = { - body: "Keep the private key in a secret manager. Document credential handling and token permissions. Use bearer tokens for authentication. Prefer bearer authentication.", - tokenPolicy: "least privilege", - secretStorage: "vault", - credentialHandling: "harness", - authorizationRequired: true, - path: "deployment.credentials.example.md", - }; - expect(sanitizeRecord(input)).toEqual(input); - expect(redactSensitiveText(input.body)).toBe(input.body); - expect(sanitizeRecord({ credentials: { provider: "opaque-value" }, passwordValue: "opaque-value", authorization_code: "opaque-value" })) - .toEqual({ credentials: REDACTED_EVENT_VALUE, passwordValue: REDACTED_EVENT_VALUE, authorization_code: REDACTED_EVENT_VALUE }); - expect(redactSensitiveText("--token-budget 4000 SECRET_STORAGE=vault")).toBe("--token-budget 4000 SECRET_STORAGE=vault"); + it("preserves the actual Copilot receipt producer discriminator through nested durable redaction", () => { + const events: Array> = []; + const projector = createCopilotToolEvidence({ sessionId: "session", turnId: "turn", workingDirectory: "/workspace", + active: () => true, emit: event => events.push(event) }); + const receipt = appendSemanticToolReceipt({ tool: "finish_task", callId: "call", arguments: {} }, + { content: [{ type: "text", text: "accepted" }] }).receipt; + expect(receipt.schema).toBe("paperclip.semantic_tool_receipt.v2"); + projector.captureSemanticReceipt()!(receipt); + expect(events).toHaveLength(1); + expect(events[0]!.payload.category).toBe("paperclip_semantic_tool_receipt_v2"); + expect(events[0]!.payload.details).toHaveLength(8); + const input = { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1, + eventType: "provider.notice.recorded", payload: events[0]!.payload } }; + expect(redactEventPayload(input)).toEqual(input); + expect(redactEventPayload(redactEventPayload(input))).toEqual(input); + }); + + it.each([1, 2] as const)("preserves public v%s receipt identifiers as text", version => { + const notice = receiptNotice(version); + expect(redactEventPayload(notice)).toEqual(notice); + // Master's JWT detector recognizes encoded headers, so a dotted public + // identifier needs no receipt-shaped exemption to survive redaction. + expect(redactEventPayload({ value: `paperclip.semantic_tool_receipt.v${version}` })) + .toEqual({ value: `paperclip.semantic_tool_receipt.v${version}` }); + }); + + it("redacts credentials in receipt-shaped data and every adjacent field", () => { + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature12345678"; + const notice = receiptNotice(); + notice.details.find((d: any) => d.name === "operationId").value = jwt; + notice.provenance.sessionId = jwt; + const redacted = redactEventPayload(notice)! as Record; + expect(receiptSchemaValue(redacted)).toBe("paperclip.semantic_tool_receipt.v1"); + expect(redacted.details.find((d: any) => d.name === "operationId").value).toBe(REDACTED_EVENT_VALUE); + expect(redacted.provenance.sessionId).toBe(REDACTED_EVENT_VALUE); + expect(redactEventPayload({ notice, password: "canary", arbitrary: jwt })) + .toMatchObject({ password: REDACTED_EVENT_VALUE, arbitrary: REDACTED_EVENT_VALUE }); + notice.details.find((d: any) => d.name === "schema").value = jwt; + notice.summary = "Authorization: Bearer canary-token"; + const hostile = redactEventPayload(notice)!; + expect(receiptSchemaValue(hostile)).toBe(REDACTED_EVENT_VALUE); + expect(JSON.stringify(hostile)).not.toContain("canary-token"); }); it("keeps the discriminator allowlist in exact PRP v1 schema parity", () => { diff --git a/server/src/__tests__/remote-pi-companion.test.ts b/server/src/__tests__/remote-pi-companion.test.ts new file mode 100644 index 0000000000..4ad3b4869f --- /dev/null +++ b/server/src/__tests__/remote-pi-companion.test.ts @@ -0,0 +1,163 @@ +import { createHash } from "node:crypto"; +import { chmodSync, linkSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +const hooks = vi.hoisted(() => ({ mkdir: undefined as undefined | ((path: string) => void), rename: undefined as undefined | ((source: string, destination: string) => void), open: undefined as undefined | ((path: string) => void), read: undefined as undefined | ((path: string, bytes: number) => void) })); +vi.mock("node:fs/promises", async (original) => { + const fs = await original(); + return { ...fs, + mkdir: async (...args: Parameters) => { hooks.mkdir?.(String(args[0])); return fs.mkdir(...args); }, + rename: async (...args: Parameters) => { hooks.rename?.(String(args[0]), String(args[1])); return fs.rename(...args); }, + open: async (...args: Parameters) => { hooks.open?.(String(args[0])); const file = await fs.open(...args); const read = file.read.bind(file); file.read = (async (...readArgs: any[]) => { const result = await (read as any)(...readArgs); hooks.read?.(String(args[0]), result.bytesRead); return result; }) as typeof file.read; return file; }, + }; +}); +vi.mock("../vendor/paperclip-runner/index.js", async () => await import("../../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js")); +import { QUALIFIED_ACPX_PROFILES } from "../../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js"; +import { createRemotePiCompanionManifest, importRemotePiCompanion, inventoryRemoteCompanion, resolveRemotePiCompanion, selectRemotePiCompanion } from "../services/native-runtime/remote-pi-companion.js"; +const roots: string[] = []; +afterEach(() => { hooks.mkdir = undefined; hooks.rename = undefined; hooks.open = undefined; hooks.read = undefined; for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); vi.restoreAllMocks(); }); +const hash = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex"); +const canonical = (v: any): string => Array.isArray(v) ? `[${v.map(canonical).join(",")}]` : v && typeof v === "object" ? `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}` : JSON.stringify(v); +async function fixture() { + const root = realpathSync(mkdtempSync(join(tmpdir(), "paperclip-companion-"))); roots.push(root); + const source = "a".repeat(40); const serverRoot = join(root, "server"); const directory = join(root, "release"); + mkdirSync(join(serverRoot, "dist"), { recursive: true }); writeFileSync(join(serverRoot, "package.json"), '{"name":"@paperclipai/server"}'); writeFileSync(join(serverRoot, "dist/build-info.json"), JSON.stringify({ commit: source })); + mkdirSync(join(directory, "bin"), { recursive: true }); mkdirSync(join(directory, "provider-pack")); + // Synthetic ELF header; these tests never launch it or claim native qualification. + const elf = Buffer.alloc(128); Buffer.from([127, 69, 76, 70, 2, 1]).copy(elf); elf.writeUInt16LE(62, 18); writeFileSync(join(directory, "bin/paperclip-runnerd"), elf, { mode: 0o755 }); + const payload = { runnerSourceRevision: source, target: { platform: "linux", architecture: "x64" }, candidateProviders: { pi: { qualification: "qualified", profileDigest: QUALIFIED_ACPX_PROFILES.pi.commandDigest, path: "provider-assets/pi/linux-x64" } } }; + writeFileSync(join(directory, "provider-pack/provider-pack.json"), JSON.stringify({ schema: "paperclip-runner/remote-provider-pack/v1", digest: `sha256:${hash(canonical(payload))}`, payload })); + const manifest = await createRemotePiCompanionManifest(directory, source); const bytes = JSON.stringify(manifest); writeFileSync(join(directory, "companion.json"), bytes); return { root, directory, serverRoot, sha256: hash(bytes), manifest, source }; +} +it("imports the release-generated closure and resolves target bytes without environment overrides", async () => { + const f = await fixture(); const result = await importRemotePiCompanion(f); expect(result.status).toBe("imported_verified"); + expect(result.runnerBinary).toBe(join(f.serverRoot, "remote-companions/linux-x64/bin/paperclip-runnerd")); + expect(readFileSync(result.runnerBinary!)).toEqual(readFileSync(join(f.directory, "bin/paperclip-runnerd"))); + expect((await importRemotePiCompanion(f)).status).toBe("verified_existing"); expect((await resolveRemotePiCompanion({ serverRoot: f.serverRoot }))?.manifestSha256).toBe(f.sha256); + expect(readdirSync(join(f.serverRoot, "remote-companions"))).toEqual(["linux-x64"]); +}); +it.each(["sha", "source", "profile", "target", "daemon", "extra", "mode", "outside-link", "outside-hardlink", "pack"])("rejects %s before publishing", async kind => { + const f = await fixture(); + if (kind === "sha") f.sha256 = "b".repeat(64); + if (kind === "source") writeFileSync(join(f.serverRoot, "dist/build-info.json"), JSON.stringify({ commit: "b".repeat(40) })); + if (kind === "profile" || kind === "target") { Object.assign(f.manifest, kind === "profile" ? { profileDigest: "sha256:" + "b".repeat(64) } : { target: "darwin-arm64" }); const bytes = JSON.stringify(f.manifest); writeFileSync(join(f.directory, "companion.json"), bytes); f.sha256 = hash(bytes); } + if (kind === "daemon") writeFileSync(join(f.directory, "bin/paperclip-runnerd"), "changed"); + if (kind === "extra") writeFileSync(join(f.directory, "extra"), "unlisted"); + if (kind === "mode") chmodSync(join(f.directory, "bin/paperclip-runnerd"), 0o777); + if (kind === "outside-link") symlinkSync("../../server/package.json", join(f.directory, "provider-pack/escape")); + if (kind === "outside-hardlink") linkSync(join(f.directory, "bin/paperclip-runnerd"), join(f.root, "alias")); + if (kind === "pack") writeFileSync(join(f.directory, "provider-pack/provider-pack.json"), "{}"); + await expect(importRemotePiCompanion(f)).rejects.toThrow(); expect(() => readdirSync(join(f.serverRoot, "remote-companions/linux-x64"))).toThrow(); +}); +it("copies contained symlinks and breaks only fully owned internal hardlinks", async () => { + const f = await fixture(); linkSync(join(f.directory, "bin/paperclip-runnerd"), join(f.directory, "bin/alias")); symlinkSync("paperclip-runnerd", join(f.directory, "bin/link")); + f.manifest = await createRemotePiCompanionManifest(f.directory, f.source); const bytes = JSON.stringify(f.manifest); writeFileSync(join(f.directory, "companion.json"), bytes); f.sha256 = hash(bytes); + expect((await importRemotePiCompanion(f)).status).toBe("imported_verified"); +}); +it("fails closed on cache corruption, foreign authority and workspace-contained cache", async () => { + const f = await fixture(); const result = await importRemotePiCompanion(f); + await expect(resolveRemotePiCompanion({ serverRoot: f.serverRoot, workspaceRoot: f.root })).rejects.toThrow("workspace"); + await expect(resolveRemotePiCompanion({ serverRoot: f.serverRoot, workspaceRoot: join(result.root!, "provider-pack") })).rejects.toThrow("workspace"); + writeFileSync(result.runnerBinary!, "corrupted"); await expect(resolveRemotePiCompanion({ serverRoot: f.serverRoot })).rejects.toThrow("inventory"); + await expect(importRemotePiCompanion(f)).rejects.toThrow(); +}); +it("preserves an existing empty destination and releases only its import lock", async () => { + const f = await fixture(); mkdirSync(join(f.serverRoot, "remote-companions/linux-x64"), { recursive: true, mode: 0o700 }); chmodSync(join(f.serverRoot, "remote-companions"), 0o700); + await expect(importRemotePiCompanion(f)).rejects.toThrow(); expect(readdirSync(join(f.serverRoot, "remote-companions/linux-x64"))).toEqual([]); expect(readdirSync(join(f.serverRoot, "remote-companions"))).toEqual(["linux-x64"]); +}); +it("rejects an unsafe cache parent and leaves it intact", async () => { + const f = await fixture(); const outside = join(f.root, "outside"); mkdirSync(outside); symlinkSync(outside, join(f.serverRoot, "remote-companions")); + await expect(importRemotePiCompanion(f)).rejects.toThrow(); expect(readdirSync(outside)).toEqual([]); +}); +it("reports missing installation without manufacturing a companion", async () => { const f = await fixture(); expect(await resolveRemotePiCompanion({ serverRoot: f.serverRoot })).toBeNull(); }); + +it("selects only normal remote Pi, preserving explicit overrides and C/C admission", async () => { + const f = await fixture(); await importRemotePiCompanion(f); const workspaceRoot = join(f.root, "workspace"); mkdirSync(workspaceRoot); + const input = { serverRoot: f.serverRoot, workspaceRoot, remote: true, provider: { kind: "acpx", agent: "pi" } }; + expect((await selectRemotePiCompanion(input))?.target).toBe("linux-x64"); + for (const patch of [{ remote: false }, { provider: { kind: "acpx", agent: "cursor" } }, { provider: { kind: "acpx", agent: "copilot" } }, { provider: { kind: "codex" } }, { binaryOverride: "/operator/runnerd" }, { packOverride: "/operator/pack" }]) expect(await selectRemotePiCompanion({ ...input, ...patch })).toBeNull(); +}); + +it("rejects an appeared empty destination without replacing it", async () => { + const f = await fixture(); const output = join(f.serverRoot, "remote-companions/linux-x64"); + hooks.mkdir = path => { if (path !== output) return; hooks.mkdir = undefined; mkdirSync(output, { mode: 0o700 }); }; + await expect(importRemotePiCompanion(f)).rejects.toThrow(); expect(readdirSync(output)).toEqual([]); + expect(readdirSync(join(f.serverRoot, "remote-companions"))).toEqual(["linux-x64"]); +}); +it("drains owned output, staging and lock cleanup after publication failure", async () => { + const f = await fixture(); hooks.rename = () => { hooks.rename = undefined; throw new Error("injected publication failure"); }; + await expect(importRemotePiCompanion(f)).rejects.toThrow("injected publication failure"); + expect(readdirSync(join(f.serverRoot, "remote-companions"))).toEqual([]); +}); +it("preserves a replaced staging root while draining the other owned cleanup", async () => { + const f = await fixture(); let replaced = ""; + hooks.rename = source => { + hooks.rename = undefined; replaced = source.slice(0, source.lastIndexOf("/")); + renameSync(replaced, replaced + "-original"); mkdirSync(replaced, { mode: 0o700 }); writeFileSync(join(replaced, "foreign"), "preserve"); + throw new Error("replaced staging"); + }; + await expect(importRemotePiCompanion(f)).rejects.toThrow("cleanup incomplete"); expect(readFileSync(join(replaced, "foreign"), "utf8")).toBe("preserve"); + expect(readdirSync(join(f.serverRoot, "remote-companions"))).not.toContain(".import-linux-x64.lock"); + expect(readdirSync(join(f.serverRoot, "remote-companions"))).not.toContain("linux-x64"); +}); +it.each(["new", "existing"])("defers actual SIGTERM on the %s import path and drains owned cleanup", async (mode) => { + const f = await fixture(); if (mode === "existing") await importRemotePiCompanion(f); + const { build } = await import("esbuild"); + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const bundle = join(f.root, "companion.mjs"); + await build({ entryPoints: [new URL("../services/native-runtime/remote-pi-companion.ts", import.meta.url).pathname], outfile: bundle, + platform: "node", format: "esm", target: "node24", bundle: true, logLevel: "silent", + plugins: [{ name: "source-owned-profile-only", setup(builder) { + builder.onResolve({ filter: /vendor\/paperclip-runner\/index\.js$/ }, () => ({ path: new URL("../../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts", import.meta.url).pathname })); + } }], + }); + const script = join(f.root, "cancel.mjs"); + writeFileSync(script, `import {importRemotePiCompanion} from './companion.mjs'; +import {readdirSync,existsSync} from 'node:fs'; +let cancelled=false, checkpoints=0,sent=false; const cancel=()=>{cancelled=true}; process.on('SIGTERM',cancel); +try { await importRemotePiCompanion({...JSON.parse(process.argv[2]),checkCancelled(){ checkpoints++; if(!sent&&existsSync(process.argv[3]+'/.import-linux-x64.lock')){sent=true;process.kill(process.pid,'SIGTERM');} if(cancelled)throw Error('owned cancellation'); }}); throw Error('unexpected success'); } +catch(error){ if(error.message!=='owned cancellation')throw error; console.log(JSON.stringify({cancelled,checkpoints,remaining:readdirSync(process.argv[3])})); } +finally {process.off('SIGTERM',cancel);} +`); + const { stdout } = await promisify(execFile)(process.execPath, [script, JSON.stringify({ directory: f.directory, sha256: f.sha256, serverRoot: f.serverRoot }), join(f.serverRoot, "remote-companions")], { env: { PATH: "/usr/bin:/bin", LANG: "C.UTF-8" }, timeout: 10_000, maxBuffer: 65536 }); + expect(JSON.parse(stdout)).toMatchObject({ cancelled: true, remaining: mode === "existing" ? ["linux-x64"] : [] }); +}); + +it("honors deadline expiry after re-verifying an existing cache without deleting it", async () => { + const f = await fixture(); await importRemotePiCompanion(f); let expired = false; + hooks.open = path => { if(path === join(f.serverRoot, "remote-companions/linux-x64/companion.json")) expired = true; }; + vi.spyOn(Date, "now").mockImplementation(() => expired ? 600_001 : 0); + await expect(importRemotePiCompanion(f)).rejects.toThrow("deadline exceeded"); + expect(readdirSync(join(f.serverRoot, "remote-companions"))).toEqual(["linux-x64"]); +}); + +async function largeFixture() { + const f = await fixture(); writeFileSync(join(f.directory, "provider-pack/large"), Buffer.alloc(8 * 1024 ** 2, 0x61)); + f.manifest = await createRemotePiCompanionManifest(f.directory, f.source); + const bytes = JSON.stringify(f.manifest); writeFileSync(join(f.directory, "companion.json"), bytes); f.sha256 = hash(bytes); return f; +} +it("yields to unrelated event-loop work between chunks of a real cache file", async () => { + const f = await largeFixture(); await importRemotePiCompanion(f); + const file = join(f.serverRoot, "remote-companions/linux-x64/provider-pack/large"); + let reads = 0; let serviced = false; let servicedBeforeNextRead = false; + hooks.read = (path, bytes) => { if(path !== file || !bytes) return; reads++; if(reads === 1) setImmediate(() => { serviced = true; }); else servicedBeforeNextRead ||= serviced; }; + expect((await resolveRemotePiCompanion({serverRoot:f.serverRoot}))?.manifestSha256).toBe(f.sha256); + expect(reads).toBeGreaterThanOrEqual(8); expect(servicedBeforeNextRead).toBe(true); +}); +it("cancels during a large existing-cache file before reading the whole file and preserves it", async () => { + const f = await largeFixture(); await importRemotePiCompanion(f); + const file = join(f.serverRoot, "remote-companions/linux-x64/provider-pack/large"); let readBytes = 0; + hooks.read = (path, bytes) => { if(path === file) readBytes += bytes; }; + await expect(importRemotePiCompanion({...f, checkCancelled(){ if(readBytes) throw Error("cancel during cache bytes"); }})).rejects.toThrow("cancel during cache bytes"); + expect(readBytes).toBeGreaterThan(0); expect(readBytes).toBeLessThan(8 * 1024 ** 2); + expect(readdirSync(join(f.serverRoot, "remote-companions"))).toEqual(["linux-x64"]); +}); +it("rejects read-only directory modes before claiming a staging or output path", async () => { + const f = await fixture(); const dir = join(f.directory, "provider-pack"); chmodSync(dir, 0o500); + try { + await expect(importRemotePiCompanion({...f, checkCancelled(){}})).rejects.toThrow("owner read/write/search"); + expect(() => readdirSync(join(f.serverRoot, "remote-companions"))).toThrow(); + } finally { chmodSync(dir, 0o755); } +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 10a76badc2..54b72aa3bd 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -410,7 +410,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { checks: [{ code: "dot_event_test_required", level: "warn" as const, message: "Dot manages its model and billing. Validate the dedicated agent binding and event round trip in Paperclip; this read-only check does not wake the Dot." }] }; } if (profile.provider === "acpx") { - if (["copilot", "pi"].includes(profile.acpxAgent)) { + if (["copilot"].includes(profile.acpxAgent)) { // The profile resolver already validated the isolated host's exact // qualification pair. Do not report a production readiness pass. return { @@ -420,7 +420,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { }; } try { - if (profile.acpxAgent !== "claude" && profile.acpxAgent !== "grok" && profile.acpxAgent !== "cursor") throw new Error("Select Codex to use the native Codex runner."); + if (profile.acpxAgent !== "claude" && profile.acpxAgent !== "grok" && profile.acpxAgent !== "cursor" && profile.acpxAgent !== "pi") throw new Error("Select Codex to use the native Codex runner."); const target = context.executionTarget; if (target?.kind === "remote") { const probe = await runAdapterExecutionTargetShellCommand( @@ -438,8 +438,8 @@ const paperclipRunnerAdapter: ServerAdapterModule = { message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." }], }; } - const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxCursorInstallation } = await import("../vendor/paperclip-runner/live/index.js"); - await (profile.acpxAgent === "grok" ? probeAcpxGrokInstallation : profile.acpxAgent === "cursor" ? probeAcpxCursorInstallation : probeAcpxClaudeInstallation)(profile.model); + const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxCursorInstallation, probeAcpxPiInstallation } = await import("../vendor/paperclip-runner/live/index.js"); + await (profile.acpxAgent === "pi" ? probeAcpxPiInstallation : profile.acpxAgent === "grok" ? probeAcpxGrokInstallation : profile.acpxAgent === "cursor" ? probeAcpxCursorInstallation : probeAcpxClaudeInstallation)(profile.model); return { adapterType: "paperclip_runner", status: "pass" as const, testedAt: new Date().toISOString(), checks: [{ code: "acpx_runtime_ready", level: "info" as const, message: `ACPX ${profile.acpxAgent} runtime is installed and verified. Model access is checked when it runs.` }], @@ -525,7 +525,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { ) : buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.160.0"), agentConfigurationDoc: - "# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude/Grok Build/Cursor through the Rust Paperclip runner and authenticated PRP transport. GitHub Copilot and Pi are awaiting local and Daytona qualification and are not enabled for production runs. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n", + "# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude/Grok Build/Cursor/Pi through the Rust Paperclip runner and authenticated PRP transport. Pi accepts an explicit provider/model ID and uses the company credentials bound to the agent environment. GitHub Copilot awaits local and Daytona qualification. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n", getConfigSchema: () => ({ fields: [ { diff --git a/server/src/redaction.ts b/server/src/redaction.ts index 8504aa33c5..31a7917a5d 100644 --- a/server/src/redaction.ts +++ b/server/src/redaction.ts @@ -878,45 +878,6 @@ function isPaperclipSchemaDiscriminator( ); } -/** Only restore the public literal in this closed receipt-notice context. - * Details use {name,value}, so the ordinary schema-key exemption cannot apply. - * Every other value still passes the generic secret/JWT redactor below. */ -function semanticReceiptSchemaDetail(record: Record): number | null { - if (Object.keys(record).sort().join(",") !== "category,details,noticeId,provenance,recoverable,schema,scope,severity,summary,userActionable" - || record.schema !== "paperclip.provider.notice.v1" || record.scope !== "turn" - || record.severity !== "info" || record.recoverable !== true || record.userActionable !== false - || record.summary !== "Paperclip returned a semantic tool result." - || typeof record.noticeId !== "string" || !/^copilot-evidence-[a-f0-9]{24}-[1-9][0-9]{0,3}$/.test(record.noticeId) - || Number(record.noticeId.slice(record.noticeId.lastIndexOf("-") + 1)) > 2048 - || !isPlainObject(record.provenance) || !Array.isArray(record.details)) return null; - const provenance = record.provenance; - if (Object.keys(provenance).sort().join(",") !== "eventType,method,sessionId,turnId" - || provenance.method !== "paperclip/semantic_tool_result" || provenance.eventType !== "semantic_result" - || ![provenance.sessionId, provenance.turnId].every(value => typeof value === "string" - && value.length > 0 && value.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(value))) return null; - const version = record.category === "paperclip_semantic_tool_receipt_v1" ? 1 - : record.category === "paperclip_semantic_tool_receipt_v2" ? 2 : null; - if (version === null || record.details.length !== (version === 1 ? 7 : 8)) return null; - const details = new Map(); - let schemaIndex: number | null = null; - for (const [index, detail] of record.details.entries()) { - if (!isPlainObject(detail) || Object.keys(detail).sort().join(",") !== "name,value" - || typeof detail.name !== "string" || typeof detail.value !== "string" || details.has(detail.name)) return null; - details.set(detail.name, detail.value); - if (detail.name === "schema") schemaIndex = index; - } - const names = ["stage", "schema", "operationId", "callIdentitySha256", "inputSha256", "resultSha256", "outcome", - ...(version === 2 ? ["normalizedInputSha256"] : [])]; - const hex = (value: string | undefined) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value); - if (!names.every(name => details.has(name)) || details.get("stage") !== "semantic_result" - || details.get("schema") !== `paperclip.semantic_tool_receipt.v${version}` - || !/^[A-Za-z0-9_.:-]{1,256}$/.test(details.get("operationId") ?? "") - || !["callIdentitySha256", "inputSha256", "resultSha256"].every(name => hex(details.get(name))) - || !["returned", "error"].includes(details.get("outcome") ?? "") - || (version === 2 && details.get("normalizedInputSha256") !== "null" && !hex(details.get("normalizedInputSha256")))) return null; - return schemaIndex; -} - export function sanitizeRecord( record: Record, ): Record { @@ -974,12 +935,6 @@ export function sanitizeRecord( } redacted[key] = sanitizeValue(value); } - const schemaIndex = semanticReceiptSchemaDetail(record); - if (schemaIndex !== null && Array.isArray(redacted.details)) { - // Restore only the exact checked discriminator, never sibling data. - (redacted.details[schemaIndex] as Record).value = - (record.details as Array>)[schemaIndex]!.value; - } return redacted; } diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 083608e3a5..3c68a3d49d 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,3 +1,4 @@ +import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js"; import { aiRoutingHarness } from "@paperclipai/shared"; import { agentIdentityService } from "../services/agent-identity.js"; import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js"; @@ -6,7 +7,6 @@ import { dotRunnerBroker } from "../services/dot-runner-broker.js"; import { publicMcpConfig } from "../services/public-mcp/oauth.js"; import { connectionIntentDeliveryService } from "../services/connection-intent-delivery.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"; diff --git a/server/src/services/agent-directory-working-copies.ts b/server/src/services/agent-directory-working-copies.ts index 78b75a68fc..8bbead8531 100644 --- a/server/src/services/agent-directory-working-copies.ts +++ b/server/src/services/agent-directory-working-copies.ts @@ -13,7 +13,7 @@ import { HttpError, conflict, notFound } from "../errors.js"; import type { AuthorizationActor } from "./authorization.js"; import type { EnvironmentRuntimeService } from "./environment-runtime.js"; import type { Environment, EnvironmentLease } from "@paperclipai/shared"; -import { agentDirectoryBaselineDigest, agentDirectoryProbeProgram, agentDirectoryTransferCleanupProgram, observeLocalAgentDirectory } from "./agent-directory-probe.js"; +import { agentDirectoryBaselineDigest, agentDirectoryProbeProgram, observeLocalAgentDirectory } from "./agent-directory-probe.js"; import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js"; import { cachedAgentFileManifest, captureAgentFileCheckpoint, checkpointBaseline, checkpointSnapshot, type AgentFileManifest } from "./agent-file-checkpoints.js"; import { logger } from "../middleware/logger.js"; @@ -23,6 +23,10 @@ export class AgentDirectoryReuseInvalidatedError extends Error {} const completed = new Set(["saved", "unchanged", "resolved", "unavailable"]); const transports = new Map(); const key = (row: Pick) => `${row.companyId}:${row.runId}`; +function sandboxTransferRoot(row: Copy, remoteCwd: string): string { + const origin = typeof row.receipt?.materializationRunId === "string" ? row.receipt.materializationRunId : String(row.receipt?.directoryRunId ?? row.runId); + return path.posix.join(remoteCwd, ".paperclip-runtime", "paperclip-runner", "agent-file-transfers", row.agentId, origin); +} export function isAgentDirectoryCopy(row: Pick | null): boolean { return row?.receipt?.schema === AGENT_FILES_CONTRACT; } @@ -62,10 +66,30 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string } return prepareAdapterExecutionTargetRuntime({ target, runId: row.runId, adapterKey: "agent-files", workspaceLocalDir: row.localRoot, workspaceRemoteDir: row.executionRoot, + // Agent files are an independently observed native directory. Transfer + // scratch belongs to the host runtime, including the fallback selected + // while plugin capability discovery is cold after controller restart. + runtimeRootDir: sandboxTransferRoot(row, target.remoteCwd), syncWorkspace: true, workspaceInboundMode: recovering ? "adopt_remote" : undefined, workspaceBaseline: baseline(row), workspaceGitSnapshot: null, workspaceFileMode: "all", workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] }); } + async function observe(row: Copy, target?: AdapterExecutionTarget | null) { + if (row.location === "local") { + // The same bounded program validates aliases on its actual host. + return observeLocalAgentDirectory(row.localRoot); + } + if (!target || target.kind !== "remote" || row.location !== `remote:${target.environmentId ?? ""}`) throw new Error("Agent directory environment changed"); + const origin = typeof row.receipt?.materializationRunId === "string" ? row.receipt.materializationRunId : row.runId; + if (row.executionRoot !== path.posix.join(target.remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, origin)) throw new Error("Agent directory root changed"); + const quote = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`; + const result = await runAdapterExecutionTargetShellCommand(row.runId, target, + `node -e ${quote(agentDirectoryProbeProgram)} ${quote(row.executionRoot)}`, { cwd: target.remoteCwd, env: {}, timeoutSec: 10 }); + if (result.exitCode !== 0 || result.timedOut || result.stdout.length > 1024) throw new Error("Agent directory observation unavailable"); + const value = JSON.parse(result.stdout); + if (!value || typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest) || typeof value.identity !== "string") throw new Error("Invalid agent directory observation"); + return value as { digest: string; identity: string }; + } async function prepareCopy(input: { companyId: string; agentId: string; runId: string; target?: AdapterExecutionTarget | null; cwd: string; warm?: boolean; reuseRunId?: string; onWarmHandoff?: (copy: Copy) => void }, serialHeld = false): Promise { const [agent] = await db.select().from(agents).where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId))); if (!agent) throw notFound("Agent not found"); @@ -189,11 +213,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string const runtime = await transport(row, input.target, false); transports.set(key(row), runtime); if (input.target.transport === "sandbox") { - if (runtime.runtimeRootDir !== path.posix.join(row.executionRoot, ".paperclip-runtime", "agent-files")) throw new Error("Agent directory transfer scratch path changed"); - const retired = await runAdapterExecutionTargetShellCommand(input.runId, input.target, - `node -e ${quote(agentDirectoryTransferCleanupProgram)} ${quote(row.executionRoot)}`, - { cwd: input.target.remoteCwd, env: {}, timeoutSec: 10 }); - if (retired.exitCode !== 0 || retired.timedOut) throw new Error("Agent directory transfer scratch could not be safely retired"); + if (runtime.runtimeRootDir !== sandboxTransferRoot(row, input.target.remoteCwd)) throw new Error("Agent directory transfer scratch path changed"); } } const observed = await observe(row, input.target).catch(() => null); @@ -394,22 +414,36 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string async function release(row: Copy, target?: AdapterExecutionTarget | null) { if (releaseIsNoop(row) || !await owns(row)) return; const destroyedOnly = row.receipt?.cleanupDestroyedOnly === true; + if (row.receipt?.retainedByRunId || row.state === "superseded" || !await owns(row) || !row.processStoppedAt) return; + // Collection and environment teardown can both release the same copy. + // A compact successful cleanup receipt is final, even after restart. + if (completed.has(row.state) && row.processStoppedAt && row.receipt?.cleanupPending === false) return; const runtime = transports.get(key(row)); transports.delete(key(row)); let cleanupPending = false; await runtime?.cleanupWorkspaceSnapshot?.().catch(() => { cleanupPending = true; }); const cleanupTarget = target ?? runtime?.target; - if (!destroyedOnly && row.processStoppedAt && completed.has(row.state) && cleanupTarget?.kind === "remote") { - const expected = path.posix.join(cleanupTarget.remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, String(row.receipt?.directoryRunId ?? row.runId)); + const lease = row.location !== "local" ? await ownedRemoteLease(row) : null; + const terminated = lease && hasRemoteTerminationReceipt(lease); + const destroyed = destroyedOnly || terminated && (lease.metadata?.remoteExecutionTermination as { state?: string } | undefined)?.state === "destroyed"; + const cleanupLeaseId = (row.receipt?.cleanup as { leaseId?: string } | undefined)?.leaseId; + // Maintenance may run while the collector is still cached. A cached target + // cannot override the current lease's stopped/destroyed/uncertain state. + const remoteUnavailable = row.location !== "local" && !destroyed && (!lease || terminated + || Boolean(lease && (lease.releasedAt || lease.status !== "active")) + || Boolean(cleanupLeaseId && (!lease || cleanupTarget?.kind === "remote" && cleanupTarget.leaseId !== lease.id))); + if (remoteUnavailable) cleanupPending = true; + else if (!destroyed && row.processStoppedAt && completed.has(row.state) && cleanupTarget?.kind === "remote") { + const expected = path.posix.join(cleanupTarget.remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, typeof row.receipt?.materializationRunId === "string" ? row.receipt.materializationRunId : String(row.receipt?.directoryRunId ?? row.runId)); if (row.executionRoot !== expected) throw new Error("Agent directory cleanup path changed"); - const quoted = `'${expected.replaceAll("'", `'"'"'`)}'`; + const paths = cleanupTarget.transport === "sandbox" ? [expected, sandboxTransferRoot(row, cleanupTarget.remoteCwd)] : [expected]; + const quoted = paths.map(p => `'${p.replaceAll("'", `'"'"'`)}'`).join(" "); const remoteCleanupFailed = await runAdapterExecutionTargetShellCommand(row.runId, cleanupTarget, `rm -rf -- ${quoted}`, { cwd: cleanupTarget.remoteCwd, env: {}, timeoutSec: 15 }).then(result => result.exitCode !== 0 || result.timedOut, () => true); cleanupPending ||= remoteCleanupFailed; - } else if (row.processStoppedAt && completed.has(row.state) && row.location !== "local") { - // Rebind only the original host-owned lease. Never acquire a replacement - // sandbox or persist transport credentials in a working-copy receipt. - cleanupPending ||= !await cleanupRemoteAfterRestart(row, destroyedOnly).catch(() => false); + } else if (!destroyed && row.processStoppedAt && completed.has(row.state) && row.location !== "local") { + // Rebind only the current host-owned lease. Never acquire a replacement. + cleanupPending ||= !await cleanupRemoteAfterRestart(row).catch(() => false); } if (completed.has(row.state) && row.processStoppedAt) { try { await fs.rm(path.dirname(row.localRoot), { recursive: true, force: true }); } @@ -437,7 +471,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string if (lease.environmentId !== null && row.location !== `remote:${lease.environmentId}`) return false; const remoteCwd = cleanup?.remoteCwd ?? lease.metadata?.remoteCwd; return typeof remoteCwd === "string" && - row.executionRoot === path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, String(row.receipt?.directoryRunId ?? row.runId)) && + row.executionRoot === path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, String(row.receipt?.materializationRunId ?? row.receipt?.directoryRunId ?? row.runId)) && hasRemoteTerminationReceipt(lease) && (lease.metadata?.remoteExecutionTermination as { state?: string }).state === "destroyed"; } async function recoverUnavailable(row: Copy) { @@ -455,10 +489,36 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string if (confirmed.state === "unavailable" && confirmed.processStoppedAt) await release(confirmed); }, "agent_directory_release"); } - async function cleanupRemoteAfterRestart(row: Copy, destroyedOnly = false): Promise { - // Newly recovered loss receipts permit no command that could wake a stopped - // provider. Recheck the exact destruction binding even after lock acquisition. - if (destroyedOnly) return destroyedRemoteCopy(row); + async function ownedRemoteLease(row: Copy) { + const cleanup = row.receipt?.cleanup as { leaseId?: string } | undefined; + const leases = await db.select().from(environmentLeases).where(and( + eq(environmentLeases.companyId, row.companyId), eq(environmentLeases.heartbeatRunId, row.runId), + cleanup?.leaseId ? eq(environmentLeases.id, cleanup.leaseId) : eq(environmentLeases.environmentId, row.location.slice("remote:".length)), + )); + if (leases.length !== 1) return null; + const lease = leases[0]!; + const destroyedWithoutEnvironment = lease.environmentId === null && hasRemoteTerminationReceipt(lease) + && (lease.metadata?.remoteExecutionTermination as { state?: string } | undefined)?.state === "destroyed"; + if (row.location !== `remote:${lease.environmentId}` && !destroyedWithoutEnvironment) return null; + return lease; + } + async function recoverStoppedRemote(row: Copy) { + const lease = await ownedRemoteLease(row); + const destroyed = lease && hasRemoteTerminationReceipt(lease) + && (lease.metadata?.remoteExecutionTermination as { state?: string } | undefined)?.state === "destroyed"; + if (!destroyed) { + // execute (including bypassSession) may restart a stopped sandbox. Keep + // the only remaining bytes; a stop receipt is not a destruction receipt. + return patch(row, { state: "pending_collection", errorCode: lease ? "INSTRUCTION_STOPPED_REMOTE_COLLECTION_PENDING" : "INSTRUCTION_REMOTE_LEASE_UNVERIFIED", + errorMessage: lease ? "The remote provider stopped with its agent directory retained. Safe stopped-file retrieval is unavailable; no save or discard is claimed." + : "The current remote lease could not be verified. Its agent directory is preserved; no retrieval, save or discard is claimed.", nextAttemptAt: new Date(Date.now() + 30_000) }); + } + row = await patch(row, { state: "unavailable", processStoppedAt: row.processStoppedAt ?? new Date(), + errorCode: "INSTRUCTION_COLLECTION_UNAVAILABLE", errorMessage: "The exact remote sandbox was destroyed before its agent directory was retrieved. No instruction save is claimed.", nextAttemptAt: null }); + await release(row); + return (await get(row.companyId, row.runId))!; + } + async function cleanupRemoteAfterRestart(row: Copy): Promise { const cleanup = row.receipt?.cleanup as { leaseId?: string; remoteCwd?: string } | undefined; const lease = await ownedRemoteLease(row); if (!lease) return false; @@ -471,7 +531,8 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string if (!environment) return false; const remoteCwd = cleanup?.remoteCwd ?? lease.metadata?.remoteCwd; if (typeof remoteCwd !== "string" || row.executionRoot !== path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, typeof row.receipt?.materializationRunId === "string" ? row.receipt.materializationRunId : String(row.receipt?.directoryRunId ?? row.runId))) return false; - const result = await environmentRuntime.execute({ environment: environment as Environment, lease: lease as EnvironmentLease, command: "rm", args: ["-rf", "--", row.executionRoot], + const paths = environment.driver === "sandbox" ? [row.executionRoot, sandboxTransferRoot(row, remoteCwd)] : [row.executionRoot]; + const result = await environmentRuntime.execute({ environment: environment as Environment, lease: lease as EnvironmentLease, command: "rm", args: ["-rf", "--", ...paths], cwd: remoteCwd, env: {}, timeoutMs: 15_000, bypassSession: true }); return result.exitCode === 0 && !result.timedOut; } @@ -484,7 +545,8 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string return fn(current); }, process.env, operation); } - return { prepare: (input: Parameters[0]) => prepareCopy(input), hasChanges, canReuse, recoverUnavailable, + return { prepare: (input: Parameters[0]) => prepareCopy(input), hasChanges, adopt, canReuse, recoverUnavailable, + recoverStoppedRemote: (row: Copy) => serial(row, current => current.receipt?.retainedByRunId ? Promise.resolve(current) : recoverStoppedRemote(current), "agent_directory_collect"), checkpointWarm: (row: Copy, target?: AdapterExecutionTarget | null) => serial(row, current => current.receipt?.warm === true ? checkpoint(current, target) : Promise.resolve(current), "agent_directory_checkpoint"), collectStopped: (row: Copy, target?: AdapterExecutionTarget | null) => serial(row, current => collectStopped(current, target), "agent_directory_collect"), release: async (row: Copy) => { diff --git a/server/src/services/agent-instruction-working-copies.ts b/server/src/services/agent-instruction-working-copies.ts index acf7089e2d..6eb2c974b6 100644 --- a/server/src/services/agent-instruction-working-copies.ts +++ b/server/src/services/agent-instruction-working-copies.ts @@ -328,12 +328,13 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen async function recoverStopped() { const pending = await db.select({ copy: copies, runtimeMode: heartbeatRuns.runtimeMode }).from(copies) .innerJoin(heartbeatRuns, and(eq(heartbeatRuns.companyId, copies.companyId), eq(heartbeatRuns.id, copies.runId))) - .where(and(or(and(or(inArray(copies.state, ["prepared", "pending_collection", "warm_saved"]), + .where(and(or(and(or(inArray(copies.state, ["prepared", "pending_collection", "unchanged_turn", "warm_saved"]), and(eq(copies.state, "preparing"), sql`${copies.receipt}->>'schema' = 'paperclip.agent-files.v1'`)), lte(copies.attempts, MAX_COLLECTION_ATTEMPTS - 1)), and(eq(copies.state, "unavailable"), isNull(copies.processStoppedAt), sql`${copies.location} like 'remote:%'`, sql`${copies.receipt}->>'schema' = 'paperclip.agent-files.v1'`)), + sql`NOT (coalesce(${copies.receipt}, '{}'::jsonb) ? 'retainedByRunId')`, inArray(heartbeatRuns.status, ["succeeded", "failed", "cancelled", "timed_out", "interrupted"]), or(isNull(copies.nextAttemptAt), lte(copies.nextAttemptAt, new Date())))).orderBy(asc(copies.updatedAt)).limit(20); for (const { copy: row, runtimeMode } of pending) { @@ -363,8 +364,8 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen } } else if (row.state === "prepared") { await patch(row, { state: "pending_collection", errorCode: "INSTRUCTION_STOP_UNCONFIRMED", - errorMessage: "The provider's stop has not been confirmed. Instruction collection is pending; no save is claimed.", nextAttemptAt: null }); - } else if (row.state === "warm_saved") { + errorMessage: "The provider's stop has not been confirmed. Instruction collection is pending; no save is claimed.", nextAttemptAt: new Date(Date.now() + 30_000) }); + } else if (["warm_saved", "pending_collection", "unchanged_turn"].includes(row.state)) { // Live retained sessions must not occupy every batch and starve stopped // copies from other agents. This does not permit reads without stop proof. await patch(row, { nextAttemptAt: new Date(Date.now() + 30_000) }); diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index 91f887ac77..0bfcd69eff 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -273,6 +273,8 @@ export function environmentRunOrchestrator( agentId: string; persistedExecutionWorkspace: Pick | null; executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null; + /** Existing live runner recovery must use its original active lease, including ephemeral leases. */ + reattachRemoteLease?: { leaseId: string; providerLeaseId: string; remoteCwd: string }; }): Promise { // Step 1: Resolve environment const selectedEnvironment = await resolveEnvironment({ @@ -286,7 +288,7 @@ export function environmentRunOrchestrator( ); // Step 2: Acquire lease - const leaseRecord = await acquireLease({ + const acquisitionInput = { companyId: input.companyId, environment, issueId: input.issueId, @@ -295,7 +297,29 @@ export function environmentRunOrchestrator( persistedExecutionWorkspace: input.persistedExecutionWorkspace, executionWorkspaceSettings: input.executionWorkspaceSettings, adapterType: input.adapterType ?? null, - }); + }; + let leaseRecord: EnvironmentRuntimeLeaseRecord; + if (input.reattachRemoteLease) { + // Reattachment is inspection of an already running sandbox, never a new + // acquisition or a lifecycle resume. Those paths can create a replacement + // when the admitted per-turn policy deliberately disables reusable leases. + const expected = input.reattachRemoteLease; + const lease = await environmentsSvc.getLeaseById(expected.leaseId); + if (!lease || environment.driver !== "sandbox" || + lease.companyId !== input.companyId || lease.environmentId !== environment.id || + lease.heartbeatRunId !== input.heartbeatRunId || lease.issueId !== input.issueId || + lease.executionWorkspaceId !== (input.persistedExecutionWorkspace?.id ?? null) || + lease.metadata?.agentId !== input.agentId || lease.status !== "active" || + lease.releasedAt !== null || lease.cleanupStatus !== null || + (lease.expiresAt !== null && new Date(lease.expiresAt).getTime() <= Date.now()) || + lease.provider !== environment.config.provider || lease.providerLeaseId !== expected.providerLeaseId || + lease.metadata?.remoteCwd !== expected.remoteCwd) { + throw new Error("native_remote_recovery_lease_mismatch"); + } + leaseRecord = { environment, lease, leaseContext: buildEnvironmentLeaseContext(input) }; + } else { + leaseRecord = await acquireLease(acquisitionInput); + } // Step 3: Log lease acquisition activity await logActivity(db, { diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index d28e072a2e..e0cd734df4 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -3875,6 +3875,86 @@ export function environmentRuntimeService( return driver; } + async function destroyScopedReusableSandboxLease(input: { + environment: Environment; + leaseRow: typeof environmentLeases.$inferSelect; + scopeCondition?: ReturnType; + failureReason: string; + }): Promise { + const now = new Date(); + const attemptId = randomUUID(); + // Persist recovery ownership before any provider work. Re-check scope and + // run liveness in this write: the earlier selection cannot fence a resume. + // The in-flight lease also excludes concurrent closures and cleanup sweeps. + const [claimed] = await db.update(environmentLeases).set({ + status: "pending_cleanup", + failureReason: input.failureReason, + cleanupStatus: "failed", + releasedAt: now, + lastUsedAt: now, + updatedAt: now, + metadata: sql`(case when jsonb_typeof(${environmentLeases.metadata}) = 'object' + then ${environmentLeases.metadata} else '{}'::jsonb end - 'remoteExecutionTermination') + || ${JSON.stringify({ pendingCleanupAttemptId: attemptId, pendingCleanupInFlight: true, + pendingCleanupLeaseExpiresAtMs: Date.now() + 15 * 60_000 })}::jsonb`, + }).where(and( + eq(environmentLeases.id, input.leaseRow.id), + eq(environmentLeases.companyId, input.leaseRow.companyId), + eq(environmentLeases.environmentId, input.environment.id), + eq(environmentLeases.leasePolicy, "reuse_by_environment"), + eq(environmentLeases.status, input.leaseRow.status), + input.scopeCondition, + sql`coalesce(${environmentLeases.metadata}->>'pendingCleanupInFlight', 'false') != 'true'`, + sql`NOT EXISTS ( + SELECT 1 FROM ${heartbeatRuns} + WHERE ${heartbeatRuns.id} = ${environmentLeases.heartbeatRunId} + AND ${heartbeatRuns.status} IN ('queued', 'scheduled_retry', 'running') + )`, + )).returning(); + if (!claimed) return null; + const lease = toEnvironmentLeaseSnapshot(claimed); + let renewalInFlight = false; + const renewal = setInterval(() => { + if (renewalInFlight) return; + renewalInFlight = true; + void db.update(environmentLeases).set({ + metadata: sql`${environmentLeases.metadata} || ${JSON.stringify({ + pendingCleanupLeaseExpiresAtMs: Date.now() + 15 * 60_000, + })}::jsonb`, + }).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.status, "pending_cleanup"), + sql`${environmentLeases.metadata}->>'pendingCleanupAttemptId' = ${attemptId}`, + sql`${environmentLeases.metadata}->>'pendingCleanupInFlight' = 'true'`, + )).then(() => {}).catch(() => { + logger.warn({ leaseId: lease.id }, "scoped cleanup ownership renewal failed"); + }).finally(() => { renewalInFlight = false; }); + }, 30_000); + renewal.unref(); + try { + const driver = getDriver(getLeaseDriverKey(lease, input.environment)); + if (!driver?.destroyRunLease) return lease; + return await driver.destroyRunLease({ + environment: input.environment, + lease, + failureReason: input.failureReason, + }) ?? lease; + } catch { + // Even a failed settlement write leaves the durable provider handle for + // the sweep. Continue with later leases without logging provider errors. + return lease; + } finally { + clearInterval(renewal); + // A crash skips this write; the bounded ownership lease lets the sweep + // recover. A completed failure becomes eligible for a later explicit retry. + await db.update(environmentLeases).set({ + metadata: sql`${environmentLeases.metadata} || '{"pendingCleanupInFlight":false}'::jsonb`, + }).where(and(eq(environmentLeases.id, lease.id), + sql`${environmentLeases.metadata}->>'pendingCleanupAttemptId' = ${attemptId}`, + )).catch(() => { + logger.warn({ leaseId: lease.id }, "scoped cleanup ownership settlement failed"); + }); + } + } + return { getDriver, @@ -4180,7 +4260,7 @@ export function environmentRuntimeService( and( eq(environmentLeases.companyId, input.companyId), eq(environmentLeases.leasePolicy, "reuse_by_environment"), - inArray(environmentLeases.status, ["active", "released", "retained", "pending_cleanup"]), + inArray(environmentLeases.status, ["active", "released", "retained", "failed", "pending_cleanup"]), ...scopeConditions, ), ); @@ -4222,18 +4302,12 @@ export function environmentRuntimeService( ? await environmentsSvc.getById(leaseRow.environmentId) : null; if (!environment) continue; - const leaseSnapshot = toEnvironmentLeaseSnapshot(leaseRow); - const driver = getDriver(getLeaseDriverKey(leaseSnapshot, environment)); - const lease = driver?.destroyRunLease - ? await driver.destroyRunLease({ - environment, - lease: leaseSnapshot, - failureReason: input.failureReason ?? "reusable_lease_destroyed", - }) - : await environmentsSvc.releaseLease(leaseSnapshot.id, "pending_cleanup", { - failureReason: input.failureReason ?? "reusable_lease_destroyed", - cleanupStatus: "failed", - }); + const lease = await destroyScopedReusableSandboxLease({ + environment, + leaseRow, + scopeCondition: and(...scopeConditions), + failureReason: input.failureReason ?? "reusable_lease_destroyed", + }); if (!lease) continue; destroyed.push({ environment, @@ -4271,7 +4345,9 @@ export function environmentRuntimeService( and( eq(environmentLeases.environmentId, input.environmentId), eq(environmentLeases.leasePolicy, "reuse_by_environment"), - inArray(environmentLeases.status, ["active", "released", "retained"]), + // A failed run may have stopped its reusable sandbox without + // destroying it; that provider handle still needs scoped teardown. + inArray(environmentLeases.status, ["active", "released", "retained", "failed"]), ), ); @@ -4302,73 +4378,20 @@ export function environmentRuntimeService( let failed = 0; let skippedLiveRun = 0; const failureReason = input.failureReason ?? "environment_delete_requested"; - const now = new Date(); for (const leaseRow of leaseRows) { if (leaseRow.heartbeatRunId && liveRunIds.has(leaseRow.heartbeatRunId)) { skippedLiveRun += 1; continue; } - // Claim the row BEFORE the provider call, mirroring the inline-teardown - // invariant used elsewhere in this file: no provider destroy without a - // durable `pending_cleanup` reference already on disk. The claim is one - // conditional UPDATE, so it is the fence against a racing resume: a - // resume that re-activates the lease first makes the status predicate - // (or the run-liveness predicate) fail and the claim loses — the live - // run keeps its sandbox. A claim that wins parks the lease where the - // cleanup sweep owns it, so a crash or thrown destroy after this point - // is recovered by the sweep's idempotent teardown, and a double write - // failure cannot strand the lease in a reusable status. - const claimedRow = await db - .update(environmentLeases) - .set({ - status: "pending_cleanup", - failureReason, - cleanupStatus: "failed", - releasedAt: now, - lastUsedAt: now, - updatedAt: now, - }) - .where( - and( - eq(environmentLeases.id, leaseRow.id), - inArray(environmentLeases.status, ["active", "released", "retained"]), - sql`NOT EXISTS ( - SELECT 1 FROM ${heartbeatRuns} - WHERE ${heartbeatRuns.id} = ${environmentLeases.heartbeatRunId} - AND ${heartbeatRuns.status} IN ('queued', 'scheduled_retry', 'running') - )`, - ), - ) - .returning() - .then((rows) => rows[0] ?? null); - if (!claimedRow) { + const lease = await destroyScopedReusableSandboxLease({ environment, leaseRow, failureReason }); + if (!lease) { // Lost to a racing resume or a concurrent terminal transition — the // lease is no longer ours to destroy. skippedLiveRun += 1; continue; } - const leaseSnapshot = toEnvironmentLeaseSnapshot(claimedRow); - try { - const driver = getDriver(getLeaseDriverKey(leaseSnapshot, environment)); - if (!driver?.destroyRunLease) { - // No driver available: the claim already parked the lease for the - // sweep, which retries once the driver's plugin is back. - failed += 1; - continue; - } - const lease = await driver.destroyRunLease({ - environment, - lease: leaseSnapshot, - failureReason, - }); - if (lease && lease.status !== "pending_cleanup") destroyed += 1; - else failed += 1; - } catch { - // The claim above already parked the lease in `pending_cleanup`, so - // the sweep owns the retry; its teardown is idempotent, so a destroy - // that reached the provider before the throw resolves as success. - failed += 1; - } + if (lease.status !== "pending_cleanup") destroyed += 1; + else failed += 1; } return { destroyed, failed, skippedLiveRun }; }, diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index c63a3974ad..4170332cc3 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -1179,8 +1179,9 @@ export function environmentService(db: Db) { where ${environmentLeases.environmentId} = ${environments.id} and ${environmentLeases.status} = 'pending_cleanup' )`, - // A reusable lease keeps a live provider sandbox after a run - // releases it. Deleting the environment would set its reference to + // A reusable lease keeps a provider sandbox after a run releases + // it, including when the run failed but release succeeded. + // Deleting the environment would set its reference to // null, and both the normal release path and scoped reusable cleanup // require that environment context. Refuse the delete atomically // until the owning issue/workspace destroys the reusable sandbox. @@ -1188,7 +1189,7 @@ export function environmentService(db: Db) { select 1 from ${environmentLeases} where ${environmentLeases.environmentId} = ${environments.id} and ${environmentLeases.leasePolicy} = 'reuse_by_environment' - and ${environmentLeases.status} in ('active', 'released', 'retained') + and ${environmentLeases.status} in ('active', 'released', 'retained', 'failed') )`, ), ) @@ -1303,7 +1304,7 @@ export function environmentService(db: Db) { and( eq(environmentLeases.environmentId, id), eq(environmentLeases.leasePolicy, "reuse_by_environment"), - inArray(environmentLeases.status, ["active", "released", "retained"]), + inArray(environmentLeases.status, ["active", "released", "retained", "failed"]), ), ), db diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index d9fbe96649..41b50c0f6e 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -154,7 +154,7 @@ import { configuredEnvironmentProjection, } from "../vendor/paperclip-runner/index.js"; import { decisionModelService } from "./decision-models.js"; -import { activeIssueInteractionCondition } from "./issue-question-context.js"; +import { activeIssueInteractionCondition, TASK_QUESTION_GUIDANCE } from "./issue-question-context.js"; import { createAgentIdentityRedactor } from "./agent-identity-redaction.js"; import { agentIdentityService, supportsManagedAgentIdentity } from "./agent-identity.js"; import { buildAgentIdentityEnv } from "@paperclipai/adapter-utils/server-utils"; @@ -165,7 +165,7 @@ import { externalObjectService } from "./external-objects.js"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { dotRunnerBroker } from "./dot-runner-broker.js"; import { isAiAuthenticationBlocked } from "./ai-auth-failure.js"; -import { nativeRetryCancellationCommitCondition, rethrowNativeCancellationLockConflict, claimCancellationRequest, startupCancellationFence } from "./native-runtime/native-cancellation-request.js"; +import { nativeRetryCancellationCommitCondition, rethrowNativeCancellationLockConflict, claimCancellationRequest } 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"; @@ -261,7 +261,7 @@ import { startAdapterExecutionTargetPaperclipBridge, } from "@paperclipai/adapter-utils/execution-target"; import { agentService } from "./agents.js"; -import { agentInstructionWorkingCopyService, instructionWorkingCopyGuidance } from "./agent-instruction-working-copies.js"; +import { agentInstructionWorkingCopyService, collectStoppedInstructionCopyWithRetries, instructionWorkingCopyGuidance } from "./agent-instruction-working-copies.js"; import { normalizeLegacyRunnerProvider, resolveManagedOpenAiBilling } from "@paperclipai/adapter-utils"; import fs from "node:fs/promises"; import path from "node:path"; @@ -15961,6 +15961,15 @@ export function heartbeatService( >; try { await controllerLease.assertOwned(); + const remoteRecovery = runOptions.nativeRestartRecovery?.kind === "reattach_remote_runner" + ? runOptions.nativeRestartRecovery : null; + const recoveryWorkspace = remoteRecovery + ? readNativeWorkspaceSyncReference(parseObject(run.runnerProfileJson).nativeWorkspaceSync) : null; + if (remoteRecovery && (!recoveryWorkspace || remoteRecovery.runId !== run.id || + recoveryWorkspace.providerLeaseId !== remoteRecovery.remote.providerLeaseId || + recoveryWorkspace.remoteCwd !== remoteRecovery.remote.remoteCwd)) { + throw new Error("native_remote_recovery_lease_mismatch"); + } acquiredEnvironment = await envOrchestrator.acquireForRun({ companyId: agent.companyId, selectedEnvironmentId, @@ -15973,6 +15982,11 @@ export function heartbeatService( agentId: agent.id, persistedExecutionWorkspace, executionWorkspaceSettings: environmentExecutionWorkspaceSettings, + ...(remoteRecovery && recoveryWorkspace ? { reattachRemoteLease: { + leaseId: recoveryWorkspace.leaseId, + providerLeaseId: remoteRecovery.remote.providerLeaseId, + remoteCwd: remoteRecovery.remote.remoteCwd, + } } : {}), }); await controllerLease.assertOwned(); nativeRunnerPreparationSpans.push({ @@ -18242,17 +18256,7 @@ export function heartbeatService( }, onLog, onEvent: onAdapterEvent, - instructionWorkingCopy: instructionCopy ? { - runId: run.id, - root: instructionCopy.executionRoot, - ...(instructionCopy.receipt?.warm === true ? { checkpointWarm: async () => { - const saved = await instructionCopies.checkpointWarm({ companyId: agent.companyId, runId: run.id, target: executionTarget }); - if (saved) await recordInstructionSave(saved); - return saved?.state === "warm_saved" && saved.errorCode === null; - } } : {}), - hasChanges: () => instructionCopies.hasChanges({ companyId: agent.companyId, runId: run.id, target: executionTarget }), - collectStopped: collectStoppedInstructions, - } : undefined, + instructionWorkingCopy: nativeInstructionWorkingCopy(), onUsage: async receipt => { await usageRecorder.capture(receipt); }, preparationSpans: nativeRunnerPreparationSpans, diff --git a/server/src/services/hot-restart.test.ts b/server/src/services/hot-restart.test.ts index 7b53fc8405..41c6a375fb 100644 --- a/server/src/services/hot-restart.test.ts +++ b/server/src/services/hot-restart.test.ts @@ -89,18 +89,20 @@ describe("hot-restart path compatibility", () => { ).not.toBeNull(); }); - it("reads Linux process start time from proc metadata", async () => { + it("reads Linux process birth from kernel ticks rather than proc directory metadata", async () => { await expect( readProcessStartedAt(123, { platform: "linux", - stat: async (target) => { - expect(target).toBe("/proc/123"); - return { - ctimeMs: Date.parse("2026-08-01T01:00:00.123Z"), - }; + linuxProcessStart: { + clockTicksPerSecond: 100, + readFile: (target) => target === "/proc/stat" + ? `btime ${Date.parse("2026-08-01T01:00:00.000Z") / 1000}\n` + : target === "/proc/123/stat" + ? `123 (runner) S ${Array(18).fill("0").join(" ")} 123\n` + : (() => { throw new Error("Unexpected proc read"); })(), }, }), - ).resolves.toBe("2026-08-01T01:00:00.123Z"); + ).resolves.toBe("2026-08-01T01:00:01.230Z"); }); it("reads macOS process start time through ps", async () => { diff --git a/server/src/services/hot-restart.ts b/server/src/services/hot-restart.ts index 30bf71e40f..1d8e7adfb0 100644 --- a/server/src/services/hot-restart.ts +++ b/server/src/services/hot-restart.ts @@ -1,6 +1,7 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; +import { readLinuxProcessStartedAt, type LinuxProcessStartOptions } from "../vendor/paperclip-runner/index.js"; import { resolvePaperclipHomeDir, resolvePaperclipInstanceId, @@ -14,7 +15,6 @@ const HOT_RESTART_LOCK_STALE_MS = 30_000; const HOT_RESTART_LOCK_TIMEOUT_MS = 10_000; type ProcessCommandRunner = (command: string, args: string[]) => Promise; -type ProcessStatReader = (target: string) => Promise<{ ctimeMs: number }>; export type HotRestartIntentRun = { runId: string; @@ -178,17 +178,15 @@ export async function readProcessStartedAt( pid: number, options: { platform?: NodeJS.Platform; - stat?: ProcessStatReader; + linuxProcessStart?: LinuxProcessStartOptions; runCommand?: ProcessCommandRunner; } = {}, ) { const platform = options.platform ?? process.platform; - const stat = options.stat ?? fs.stat; const runCommand = options.runCommand ?? runProcessCommand; if (platform === "linux") { - const processStat = await stat(`/proc/${pid}`); - return new Date(processStat.ctimeMs).toISOString(); + return readLinuxProcessStartedAt(pid, options.linuxProcessStart); } if (["darwin", "freebsd", "openbsd", "aix", "sunos"].includes(platform)) { diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 9faf19d2f3..d28d6a4ec0 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -1,3 +1,4 @@ +import { normalizeEscapedLineBreaks } from "@paperclipai/shared/validators/text"; import { activeIssueInteractionCondition, historicalQuestionCondition } from "./issue-question-context.js"; import { currentContinuationOrigins, diff --git a/server/src/services/native-runtime/native-agent-runtime-inheritance.test.ts b/server/src/services/native-runtime/native-agent-runtime-inheritance.test.ts index 3f12e85fa7..fc1d3ef525 100644 --- a/server/src/services/native-runtime/native-agent-runtime-inheritance.test.ts +++ b/server/src/services/native-runtime/native-agent-runtime-inheritance.test.ts @@ -47,3 +47,7 @@ describe("native hire runtime inheritance", () => { .not.toHaveProperty("managedProfileId"); }); }); + +it("preserves Pi thinking configuration without live identity or credentials", () => { + expect(inheritNativeRunnerAdapterConfig({ provider: "acpx", acpxAgent: "pi", piThinkingLevel: "low", runtimeSessionId: "prior", env: { OPENROUTER_API_KEY: "canary" } })).toEqual({ provider: "acpx", acpxAgent: "pi", piThinkingLevel: "low" }); +}); 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 7167b70c49..6059636576 100644 --- a/server/src/services/native-runtime/native-agent-runtime-inheritance.ts +++ b/server/src/services/native-runtime/native-agent-runtime-inheritance.ts @@ -14,6 +14,7 @@ export const INHERITABLE_NATIVE_RUNNER_CONFIG_KEYS = [ "opencodePermissionMode", "acpxPermissionMode", "acpxSessionMode", + "piThinkingLevel", "lifecycleMode", "modelReasoningEffort", "maxIterations", 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 deleted file mode 100644 index 9d71ed4f0d..0000000000 --- a/server/src/services/native-runtime/native-cursor-plan-wait.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -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 deleted file mode 100644 index 9825fff410..0000000000 --- a/server/src/services/native-runtime/native-cursor-plan-wait.ts +++ /dev/null @@ -1,243 +0,0 @@ -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-deliverable-feedback.test.ts b/server/src/services/native-runtime/native-deliverable-feedback.test.ts index bb9d7d9abd..eb6831d653 100644 --- a/server/src/services/native-runtime/native-deliverable-feedback.test.ts +++ b/server/src/services/native-runtime/native-deliverable-feedback.test.ts @@ -12,6 +12,23 @@ describe("explicit file output requirements", () => { "Make a file but do not send it to anyone else.", "Export a summary of this PDF as CSV.", "Create no temporary files; export the results as CSV.", + "Write report.pdf. This is an internal verification file, not a deliverable. Also export the results as CSV.", + "Write a downloadable report.pdf. This is personal memory, not a task deliverable.", + "Attempt native write to report.txt with content requested.", + "Write report.pdf and checklist.md. This is an internal assertion file, not a deliverable.", + "Write report.pdf; write internal-proof.txt. This is an internal verification file, not a deliverable.", + "Write report.pdf and attempt native write to /outside/probe.txt; this negative test must be denied.", + "Attempt native write to /outside/probe.json and write report.pdf; this negative test must be denied.", + "Write report.txt and attach it. This is an internal verification file, not a deliverable.", + "Attach it.", + "Write report.txt and return it as an attachment. This is an internal verification file, not a deliverable.", + "Write report.txt and send it to me. This is an internal verification file, not a deliverable.", + "Write report.txt. This is an internal verification file, not a deliverable. Send it to me.", + "Write report.txt and provide it to me. This is an internal verification file, not a deliverable.", + "Write report.txt and give me it. This is an internal verification file, not a deliverable.", + "Write report.txt and return it. This is an internal verification file, not a deliverable.", + "Write report.txt and send it as an attachment in your response. This is an internal verification file, not a deliverable.", + "Write report.txt. This is an internal verification file, not a deliverable. Send it as a download link in your response.", ])("recognizes an explicit output request: %s", objective => { expect(explicitlyRequestsFileOutput(objective)).toBe(true); }); @@ -31,6 +48,20 @@ describe("explicit file output requirements", () => { "Create no files.", "Generate no attachments and answer in chat.", "Write a reply without any files.", + "Use native write/read file tools for this task, not bash or the instructions API.", + "Write a memory entry to memory/pi-native.txt inside the registered AGENT_HOME. This is personal memory, not a task deliverable.", + "Use native write to copy those exact bytes into pi-agent-memory-proof.txt in the task workspace. This is an internal assertion file, not a deliverable.", + "Before finishing, attempt native write exactly once to /outside/pi-unassigned.txt with content forbidden. This intentionally unassigned root must be denied.", + "Write internal-proof.txt; then check it exists. This is an internal verification file, not a deliverable.", + "Attempt native write to /outside/probe.txt and create no files. This negative test must be denied.", + "Write internal-proof.txt; do not attach it. This is an internal verification file, not a deliverable.", + "Write a reply and return it in chat.", + "Write internal-proof.txt and return it in chat. This is an internal verification file, not a deliverable.", + "Write internal-proof.txt and send it in chat. This is an internal verification file, not a deliverable.", + "Write internal-proof.txt. This is an internal verification file, not a deliverable. Return it inline.", + "Write internal-proof.txt and send it as a code block in your response. This is an internal verification file, not a deliverable.", + "Write internal-proof.txt and return it in my reply. This is an internal verification file, not a deliverable.", + "Write internal-proof.txt and provide it as plain text. This is an internal verification file, not a deliverable.", ])("does not require a file for a text or source-review request: %s", objective => { expect(explicitlyRequestsFileOutput(objective)).toBe(false); }); diff --git a/server/src/services/native-runtime/native-deliverable-feedback.ts b/server/src/services/native-runtime/native-deliverable-feedback.ts index 668c2ff051..ff8dc44388 100644 --- a/server/src/services/native-runtime/native-deliverable-feedback.ts +++ b/server/src/services/native-runtime/native-deliverable-feedback.ts @@ -63,26 +63,59 @@ async function hasCurrentPublicationReceipt(db: Db, companyId: string, receipts: * an output requirement or erase a user's request for a file. */ export function explicitlyRequestsFileOutput(objective: string): boolean { - return objective.split(/(?:[.!?](?:\s|$)|\n|[;,]|\bbut\b)/iu).some(clause => { + const sentences = objective.replaceAll("\\_", "_").split(/(?:[.!?](?:\s|$)|\n)/iu); + let precedingFileOutput = false; + return sentences.some((sentence, index) => { const file = /\b(?:files?|attachments?|downloads?|pdf|spreadsheets?|workbooks?|slide decks?|powerpoints?|docx|xlsx|csv)\b|\b[^\s/]+\.(?:md|txt|pdf|docx?|xlsx?|csv|pptx?|png|jpe?g|svg|zip)\b/giu; - const create = /\b(?:create|make|write|save|export|attach|send|generate|produce|prepare|provide|give|return|build)\b/iu.exec(clause); - if (create && /\b(?:do not|don't|never|no need to)\s*$/iu.test(clause.slice(0, create.index))) return false; - const output = create ? clause.slice(create.index + create[0].length) : ""; - const fileObject = [...output.matchAll(file)].some(match => { - const prefix = output.slice(0, match.index); - const suffix = output.slice(match.index + match[0].length); - // "Create no files" is a prohibition, even though it contains a creation - // verb. Negate this object only; another explicit output can still count. - if (/\b(?:no|zero|without(?:\s+any)?)\s+(?:(?:new|temporary|downloadable|attached|additional)\s+)*$/iu.test(prefix)) return false; - // "Write a summary of this PDF" names input, not a requested file. - // Explicit export destinations still count after such input references. - const destination = /\b(?:as|into|to)\s+(?:(?:a|an|the|new|separate|markdown|word|excel)\s+)*$/iu.test(prefix); - if (!destination && /\b(?:of|about|on|from|using|for|with)\b/iu.test(prefix)) return false; - if (/^files?$/iu.test(match[0]) && /^\s+(?:permissions?|systems?|formats?|names?|paths?|types?|sizes?|descriptors?)\b/iu.test(suffix)) return false; - return true; + const outputs = sentence.split(/(?:[;,]|\bbut\b)/iu).flatMap(clause => { + const creates = [...clause.matchAll(/\b(?:create|make|write|save|export|attach|send|generate|produce|prepare|provide|give|return|build)\b/giu)]; + return creates.flatMap((create, createIndex) => { + const before = clause.slice(0, create.index); + if (/\b(?:do not|don't|never|no need to)\s*$/iu.test(before)) return []; + // Bind each object to its own verb. A denied write or "create no files" + // cannot suppress a separate requested report in this same clause. + const output = clause.slice(create.index + create[0].length, creates[createIndex + 1]?.index); + const objects = [...output.matchAll(file)].filter(match => { + const prefix = output.slice(0, match.index); + const suffix = output.slice(match.index + match[0].length); + // "Create no files" is a prohibition, even though it contains a creation + // verb. Negate this object only; another explicit output can still count. + if (/\b(?:no|zero|without(?:\s+any)?)\s+(?:(?:new|temporary|downloadable|attached|additional)\s+)*$/iu.test(prefix)) return false; + // "Write a summary of this PDF" names input, not a requested file. + // Explicit export destinations still count after such input references. + const destination = /\b(?:as|into|to)\s+(?:(?:a|an|the|new|separate|markdown|word|excel)\s+)*$/iu.test(prefix); + if (!destination && /\b(?:of|about|on|from|using|for|with)\b/iu.test(prefix)) return false; + if (/^files?$/iu.test(match[0]) && /^\s+(?:tools?|permissions?|systems?|formats?|names?|paths?|types?|sizes?|descriptors?)\b/iu.test(suffix)) return false; + return true; + }); + // An explicit attachment/export request can refer to the file by + // pronoun. It still requires publication when its name is omitted. + const referencesOutput = /^(?:attach|export|send|provide|give|return)$/iu.test(create[0]) + && /^\s+(?:me\s+)?(?:it|them|this|that)\b/iu.test(output); + const referencedAttachment = referencesOutput && /^(?:attach|export)$/iu.test(create[0]); + const inline = /\b(?:inline|(?:in|within|inside|as|into)\s+(?:(?:a|an|the|my|your|our|final|plain|markdown|chat|fenced)\s+)*(?:chat|response|reply|message|comment|text|code block)|(?:its|the) contents)\b/iu.test(output); + const downloadable = referencedAttachment || (!/\b(?:no|without)\s+(?:downloadable|attached)/iu.test(output) + && /\b(?:downloadable|attached)\s+(?:file|report|document|checklist|draft)\b/iu.test(output)); + const publication = /\b(?:downloadable|attached|attach|export|send|provide|return|give)\b/iu.test(create[0] + output); + const deniedAttempt = create[0].toLowerCase() === "write" && objects.length === 1 + && /\b(?:attempt|try)\s+(?:the\s+)?native\s*$/iu.test(before) + && /\b(?:must be denied|denial is (?:the )?expected|(?:this|the) negative test)\b/iu.test(objective); + if (objects.length === 0 && !downloadable && /^\s+(?:no|zero|without)\b/iu.test(output)) return []; + return [{ objects: objects.length, downloadable, publication, deniedAttempt, + referencesOutput, publicationReference: referencesOutput && !inline }]; + }); + }); + // "This ..." qualifies a single requested file in the preceding sentence, + // even when a later clause checks it. Multiple files cannot share this + // exception and separate report requests still require publication. + const internal = outputs.reduce((count, output) => count + output.objects + Number(output.downloadable && output.objects === 0), 0) === 1 + && /^\s*This is (?:an? )?(?:personal memory|internal (?:assertion|verification) file)\b[^.!?]*\bnot a (?:task )?deliverable\b/iu.test(sentences[index + 1] ?? ""); + return outputs.some(output => { + const publicationReference = output.publicationReference && precedingFileOutput; + if (!output.referencesOutput) precedingFileOutput = output.objects > 0; + return (output.objects > 0 || output.downloadable || publicationReference) + && (output.publication || (!internal && !output.deniedAttempt)); }); - return fileObject || - (!/\b(?:no|without)\s+(?:downloadable|attached)/iu.test(clause) && /\b(?:downloadable|attached)\s+(?:file|report|document|checklist|draft)\b/iu.test(clause)); }); } diff --git a/server/src/services/native-runtime/native-execution-input.test.ts b/server/src/services/native-runtime/native-execution-input.test.ts index 75194d98c8..8abc557e83 100644 --- a/server/src/services/native-runtime/native-execution-input.test.ts +++ b/server/src/services/native-runtime/native-execution-input.test.ts @@ -602,7 +602,7 @@ describe("Cursor mode native execution projection", () => { } it.each([undefined, "agent", "plan", "ask"] as const)("preserves mode %s independently of permissions and task planning", mode => { const result = buildNativeExecutionInput(fixture(mode)); - expect(result.provider).toMatchObject({ kind: "acpx", agent: "cursor", cursorMode: mode ?? "agent", permissionMode: "deny-all" }); + expect(result.provider).toMatchObject({ kind: "acpx", agent: "cursor", mode: mode ?? "agent", permissionMode: "deny-all" }); expect(result.executionMode).toBe("default"); expect(result.task.workMode).toBe("standard"); }); @@ -610,3 +610,27 @@ describe("Cursor mode native execution projection", () => { expect(() => buildNativeExecutionInput({ ...fixture("plan"), acpxAgent: "copilot" })).toThrow("only for Cursor"); }); }); + +describe("Pi thinking native execution projection", () => { + function fixture(piThinkingLevel?: "off" | "low" | "high" | "max") { + return { + companyId: "company-1", runId: "run-1", agentId: "agent-1", + issue: { id: "issue-1", identifier: "MODE-1", title: "Review a plan", description: null, workMode: "standard" }, + taskPrompt: "Review the project.", + workspace: { id: "workspace-1", cwd: "/workspace", repoUrl: null, repoRef: null, branchName: null }, + normalizedSessionId: null, provider: "acpx" as const, acpxAgent: "pi" as const, + model: "openrouter/deepseek/deepseek-v4-flash-0731", piThinkingLevel, acpxPermissionMode: "deny-all" as const, + completionContract: { id: "contract-1", sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1", contract: { revision: "1", objective: "Review", criteria: [{ id: "output", requirement: "Review" }] } }, + runtimeContext: nativeRuntimeContextFixture(), + }; + } + it.each([undefined, "off", "low", "high", "max"] as const)("preserves mode %s independently of permissions and task planning", mode => { + const result = buildNativeExecutionInput(fixture(mode)); + expect(result.provider).toMatchObject({ kind: "acpx", agent: "pi", piThinkingLevel: mode ?? "low", permissionMode: "deny-all" }); + expect(result.executionMode).toBe("default"); + expect(result.task.workMode).toBe("standard"); + }); + it("rejects a Pi thinking level attached to another harness", () => { + expect(() => buildNativeExecutionInput({ ...fixture("low"), acpxAgent: "copilot" })).toThrow("only for Pi"); + }); +}); diff --git a/server/src/services/native-runtime/native-execution-input.ts b/server/src/services/native-runtime/native-execution-input.ts index da55c57790..280acc1836 100644 --- a/server/src/services/native-runtime/native-execution-input.ts +++ b/server/src/services/native-runtime/native-execution-input.ts @@ -1,5 +1,5 @@ import type { PaperclipTurnContext } from "@paperclipai/adapter-utils/server-utils"; -import { resolvePaperclipRunnerCursorMode } from "@paperclipai/adapter-utils"; +import { resolvePaperclipRunnerCursorMode, resolvePaperclipRunnerPiThinkingLevel } from "@paperclipai/adapter-utils"; import { createHash } from "node:crypto"; import { buildNativeContinuationPrompt } from "./native-continuation.js"; import type { @@ -77,6 +77,7 @@ export interface BuildNativeExecutionInput { opencodePermissionMode?: NativeOpenCodePermissionMode; acpxPermissionMode?: NativeAcpxPermissionMode; acpxSessionMode?: "agent" | "plan" | "ask"; + piThinkingLevel?: "off" | "low" | "high" | "max"; model?: string | null; managedProfile?: Extract< NativeExecutionInputV5["provider"], @@ -113,6 +114,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, or ask"); } const mode = resolvePaperclipRunnerCursorMode(input.provider, input.acpxAgent, input.acpxSessionMode); + const piThinkingLevel = resolvePaperclipRunnerPiThinkingLevel(input.provider, input.acpxAgent, input.piThinkingLevel); const executionMode = input.executionMode ?? (input.issue.workMode === "planning" ? "plan" : "default"); const acpxProfile = input.provider === "acpx" @@ -276,6 +278,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat model: input.model, permissionMode: input.acpxPermissionMode ?? "approve-all", ...(mode === undefined ? {} : { mode }), + ...(piThinkingLevel === undefined ? {} : { piThinkingLevel }), profile: { driverKind: acpxProfile!.driverKind, protocolVersion: acpxProfile!.protocolVersion, diff --git a/server/src/services/native-runtime/native-github-access.ts b/server/src/services/native-runtime/native-github-access.ts index f0245291f4..a9711329ff 100644 --- a/server/src/services/native-runtime/native-github-access.ts +++ b/server/src/services/native-runtime/native-github-access.ts @@ -1,8 +1,8 @@ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; -import path from "node:path"; import { cleanupGitHubOperationLaunchers, + githubOperationLauncherDirectory, prepareGitHubOperationLaunchers, startAdapterExecutionTargetPaperclipBridge, } from "@paperclipai/adapter-utils/execution-target"; @@ -98,7 +98,7 @@ export async function createNativeGitHubAccess(input: { bridge = await startBridge({ ...location, runtimeRootDir: input.target?.kind === "remote" - ? path.posix.join(input.target.remoteCwd, ".paperclip-runtime", "github", location.runId) + ? githubOperationLauncherDirectory(location) : null, adapterKey: "native-github", hostApiToken: token, diff --git a/server/src/services/native-runtime/native-runner-file-handoff.test.ts b/server/src/services/native-runtime/native-runner-file-handoff.test.ts index 7f4d5c025e..8b9f7f502f 100644 --- a/server/src/services/native-runtime/native-runner-file-handoff.test.ts +++ b/server/src/services/native-runtime/native-runner-file-handoff.test.ts @@ -332,6 +332,42 @@ describe("native runner file handoff", () => { } }); + it.each([ + "Use native write/read file tools. Write memory/pi-native.txt inside AGENT_HOME. This is personal memory, not a task deliverable.", + "Use native write to copy bytes into pi-agent-memory-proof.txt. This is an internal assertion file, not a deliverable.", + "Attempt native write once to /outside/pi-unassigned.txt. This intentionally unassigned root must be denied.", + "Write internal-proof.txt; then check it exists. This is an internal verification file, not a deliverable.", + "Attempt native write to /outside/probe.txt and create no files. This negative test must be denied.", + "Write internal-proof.txt and return it in chat. This is an internal verification file, not a deliverable.", + "Write internal-proof.txt and send it as a code block in your response. This is an internal verification file, not a deliverable.", + ])("accepts an internal file outcome without treating it as published output: %s", async objective => { + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, executionContinuation: { objective } } }) + .where(eq(heartbeatRuns.id, runId)); + try { + await expect(nativeCompletionFeedback(db, runId, doneReport([]))) + .resolves.toContain("Completion report accepted"); + } finally { + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId } }).where(eq(heartbeatRuns.id, runId)); + } + }); + + it.each([ + "Write report.pdf; write internal-proof.txt. This is an internal verification file, not a deliverable.", + "Write report.pdf and attempt native write to /outside/probe.txt; this negative test must be denied.", + "Write report.txt and attach it. This is an internal verification file, not a deliverable.", + "Write report.txt and send it to me. This is an internal verification file, not a deliverable.", + "Write report.txt. This is an internal verification file, not a deliverable. Send it as a download link in your response.", + ])("still requires publication when the task also asks for internal or denied writes: %s", async objective => { + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, executionContinuation: { objective } } }) + .where(eq(heartbeatRuns.id, runId)); + try { + await expect(nativeCompletionFeedback(db, runId, doneReport([]))) + .rejects.toThrow("requested file has no accessible delivery evidence"); + } finally { + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId } }).where(eq(heartbeatRuns.id, runId)); + } + }); + it("prepares one verified same-run attachment and replays without duplicates", async () => { const body = Buffer.from("native runner file handoff\n", "utf8"); await mkdir(path.join(workspaceRoot, "out"), { recursive: true }); 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 c3c4040d73..6ed892e215 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -1268,12 +1268,23 @@ describe("remote provider pack manifest", () => { Object.assign(payload, { candidateProviders: candidates }); await writeManifest(); expect(readRemoteProviderPackManifest(root).payload.candidateProviders?.pi?.qualification).toBe("qualified"); + // The normal pack builder publishes Pi in both inventories. A Pi image + // must pass the same reader used by real remote runtime preparation. + Object.assign(payload, { providers: { cursor, pi: candidates.pi } }); + await writeManifest(); + expect(readRemoteProviderPackManifest(root).payload.providers?.pi?.qualification).toBe("qualified"); + await mkdir(releaseMetadata, { recursive: true }); + await cp(join(root, "provider-pack.json"), manifestPath); + expect(readBundledRemoteProviderPackManifest(manifestPath).payload.providers?.pi?.qualification).toBe("qualified"); + Object.assign(payload, { providers: { cursor } }); + await rm(releaseMetadata, { recursive: true, force: true }); + await writeManifest(); await writeFile(join(root, candidatePath, "runtime"), "substitute runtime"); expect(() => readRemoteProviderPackManifest(root)).toThrow("candidate asset tree digest mismatch"); await writeFile(join(root, candidatePath, "runtime"), "pinned runtime"); candidates.pi.qualification = "pending"; await writeManifest(); - expect(readRemoteProviderPackManifest(root).payload.candidateProviders?.pi?.qualification).toBe("pending"); + expect(() => readRemoteProviderPackManifest(root)).toThrow("invalid candidate identity"); candidates.pi.qualification = "qualified"; for (const invalid of [{ path: "../outside" }, { qualification: "unknown" }]) { const original = { ...candidates.pi }; @@ -1285,7 +1296,7 @@ describe("remote provider pack manifest", () => { for (const provider of ["cursor", "copilot"]) { Object.assign(candidates, { [provider]: { ...candidates.pi, path: `provider-assets/${provider}/linux-x64` } }); await writeManifest(); - expect(() => readRemoteProviderPackManifest(root)).toThrow("invalid candidate identity"); + expect(() => readRemoteProviderPackManifest(root)).toThrow(provider === "cursor" ? "Cursor profile or closure does not match this release" : "invalid candidate identity"); delete (candidates as Record)[provider]; } await writeManifest(); @@ -1560,12 +1571,12 @@ describe("verified native harness backups", () => { it("binds Cursor mode across governed and identical recovery identities", () => { const execution = { ...backupExecution, - provider: { kind: "acpx", agent: "cursor", model: "explicit-model", cursorMode: "plan" }, + provider: { kind: "acpx", agent: "cursor", model: "explicit-model", mode: "plan" }, interactionResponses: [{ interactionId: "interaction-1" }], } as unknown as NativeExecutionInputV1; - const identity = (suffix: string, cursorMode: unknown) => { + const identity = (suffix: string, mode: unknown) => { const value = acpxIdentity(suffix); - return { ...value, providerSessionIdentity: { ...value.providerSessionIdentity, cursorMode } }; + return { ...value, providerSessionIdentity: { ...value.providerSessionIdentity, mode } }; }; const previous = identity("previous", "plan"); const current = identity("current", "plan"); @@ -1580,7 +1591,37 @@ describe("verified native harness backups", () => { for (const provider of [ { kind: "acpx", agent: "cursor", model: "explicit-model" }, { kind: "acpx", agent: "copilot", model: "explicit-model" }, - { kind: "acpx", agent: "copilot", model: "explicit-model", cursorMode: "plan" }, + ]) { + expect(providerSessionIdentityTransitionIsAllowed({ + execution: { ...execution, provider } as unknown as NativeExecutionInputV1, previous, current, + })).toBe(false); + } + }); + + it("binds Pi thinking across governed and identical recovery identities", () => { + const execution = { + ...backupExecution, + provider: { kind: "acpx", agent: "pi", model: "explicit-model", piThinkingLevel: "low" }, + interactionResponses: [{ interactionId: "interaction-1" }], + } as unknown as NativeExecutionInputV1; + const identity = (suffix: string, piThinkingLevel: unknown) => { + const value = acpxIdentity(suffix); + return { ...value, providerSessionIdentity: { ...value.providerSessionIdentity, piThinkingLevel } }; + }; + const previous = identity("previous", "low"); + const current = identity("current", "low"); + expect(providerSessionIdentityTransitionIsAllowed({ execution, previous, current })).toBe(true); + expect(providerSessionIdentityTransitionIsAllowed({ execution, previous, current: previous })).toBe(true); + for (const mode of [undefined, null, "high", "max", "medium"]) { + const wrong = identity("wrong", mode); + expect(providerSessionIdentityTransitionIsAllowed({ execution, previous, current: wrong })).toBe(false); + expect(providerSessionIdentityTransitionIsAllowed({ execution, previous: wrong, current })).toBe(false); + expect(providerSessionIdentityTransitionIsAllowed({ execution, previous: wrong, current: wrong })).toBe(false); + } + for (const provider of [ + { kind: "acpx", agent: "pi", model: "explicit-model" }, + { kind: "acpx", agent: "copilot", model: "explicit-model" }, + { kind: "acpx", agent: "copilot", model: "explicit-model", piThinkingLevel: "low" }, ]) { expect(providerSessionIdentityTransitionIsAllowed({ execution: { ...execution, provider } as unknown as NativeExecutionInputV1, previous, current, @@ -1952,24 +1993,23 @@ describe("split durable provider checkpoint identity", () => { it("requires the exact observed Cursor mode in suspended descriptor and identity", () => { const profileDigest = `sha256:${"a".repeat(64)}`; - const input = execution({ kind: "acpx", agent: "cursor", model: "explicit-model", permissionMode: "deny-all", cursorMode: "plan" }, "acpx_runtime"); + const input = execution({ kind: "acpx", agent: "cursor", model: "explicit-model", permissionMode: "deny-all", mode: "plan" }, "acpx_runtime"); const providerState = { schema: "paperclip.runner.acpx-provider-state.v3", lifecycle: "suspended", activeTurnId: null, providerExitUnconfirmed: false, - descriptor: { kind: "acpx", provider: "acpx", driver: "acpx_runtime", agent: "cursor", model: "explicit-model", commandDigest: profileDigest, normalizedSessionId: "native-session", cursorMode: "plan" }, + descriptor: { kind: "acpx", provider: "acpx", driver: "acpx_runtime", agent: "cursor", model: "explicit-model", commandDigest: profileDigest, normalizedSessionId: "native-session", mode: "plan" }, identity: { kind: "acpx", normalizedSessionId: "native-session", acpxRecordId: "record-1", backendSessionId: "backend-1", agentSessionId: "agent-1", profileDigest, - workspaceDigest: `sha256:${"b".repeat(64)}`, requestedModel: "explicit-model", effectiveModel: "explicit-model", permissionMode: "deny-all", cursorMode: "plan", providerLifetimeFenceCandidates: [53001, 53002, 53003] }, + workspaceDigest: `sha256:${"b".repeat(64)}`, requestedModel: "explicit-model", effectiveModel: "explicit-model", permissionMode: "deny-all", mode: "plan", providerLifetimeFenceCandidates: [53001, 53002, 53003] }, }; const read = (state: unknown, candidate = input) => providerSessionIdentityFromDurableProviderState({ execution: candidate, providerState: state }); expect(read(providerState).providerSessionIdentity).toEqual(providerState.identity); - for (const cursorMode of [undefined, null, "agent", "ask", "autopilot"]) { - expect(read({ ...providerState, identity: { ...providerState.identity, cursorMode } }).providerSessionIdentity).toBeNull(); - expect(read({ ...providerState, descriptor: { ...providerState.descriptor, cursorMode } }).providerSessionIdentity).toBeNull(); - expect(read(providerState, execution({ ...input.provider, cursorMode }, "acpx_runtime")).providerSessionIdentity).toBeNull(); + for (const mode of [undefined, null, "agent", "ask", "autopilot"]) { + expect(read({ ...providerState, identity: { ...providerState.identity, mode } }).providerSessionIdentity).toBeNull(); + expect(read({ ...providerState, descriptor: { ...providerState.descriptor, mode } }).providerSessionIdentity).toBeNull(); + expect(read(providerState, execution({ ...input.provider, mode }, "acpx_runtime")).providerSessionIdentity).toBeNull(); } const foreign = { ...providerState, descriptor: { ...providerState.descriptor, agent: "copilot" } }; - for (const cursorMode of [undefined, "plan"]) { - expect(read(foreign, execution({ ...input.provider, agent: "copilot", cursorMode }, "acpx_runtime")).providerSessionIdentity).toBeNull(); - } + expect(read(foreign, execution({ ...input.provider, agent: "copilot", mode: undefined }, "acpx_runtime")).providerSessionIdentity).toBeNull(); + expect(read(foreign, execution({ ...input.provider, agent: "copilot", mode: "plan" }, "acpx_runtime")).providerSessionIdentity).toEqual(providerState.identity); }); it.each([ @@ -4914,6 +4954,16 @@ describe("native governed waits", () => { payload: { kind: "dynamicToolCall" }, }; + // The durable fallback preserves the question after provider loss, but + // cannot turn that lost execution into a successful governed wait. + const providerLost = { ...replayedEvent, eventType: "runtime_request.expired" as const, + payload: { reason: "provider_process_lost", replayAllowed: false } }; + const lostObservation = createGovernedWaitEventObservation(async () => waitResult); + await lostObservation.observe(providerLost, true); + expect(lostObservation.consume(providerLost)).toBeNull(); + await lostObservation.observe(replayedEvent, true); + expect(lostObservation.consume(replayedEvent)).toBeNull(); + // The event consumer can lag the provider: a commentary event emitted // before the tool began may be processed after its approval exists in DB. // It is not proof that the approval-creating tool response has settled. @@ -9411,7 +9461,7 @@ describe("native process ownership", () => { }, ); - it.each(["pi", "copilot"])("rejects ACPX candidate %s without host authorization before constructing a backend", async (agent) => { + it.each(["copilot"])("rejects ACPX candidate %s without host authorization before constructing a backend", async (agent) => { const piExecution = { ...execution, binding: { ...execution.binding, runId: "run-acpx-pi-rejected" }, diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 011160760e..7199b98904 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -1,6 +1,6 @@ +import { configuredEnvironment } from "../../vendor/paperclip-runner/index.js"; import { agents } from "@paperclipai/db"; import { dotRunnerBroker } from "../dot-runner-broker.js"; -import { configuredEnvironment } from "../../vendor/paperclip-runner/index.js"; import { CURSOR_DISTRIBUTION_PINS, QUALIFIED_ACPX_PROFILES, QUALIFIED_ACPX_VERSION } from "../../vendor/paperclip-runner/index.js"; import { isProviderMode } from "../../vendor/paperclip-runner/index.js"; import { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "../../vendor/paperclip-runner/index.js"; @@ -38,6 +38,7 @@ import { nativeCompletionFeedback } from "./native-completion-feedback.js"; import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js"; import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js"; import { prepareVerifiedRemoteProviderPack } from "./remote-provider-pack.js"; +import { selectRemotePiCompanion } from "./remote-pi-companion.js"; import { readNativeLocalProcessStop, PROCESS_START_REQUESTED } from "../native-local-process-stop.js"; import { remoteLeaseCleanupScope } from "../remote-execution-termination.js"; import { resolveConnectorAssignments, isConnectorSkill } from "../connector-runtime.js"; @@ -1248,6 +1249,7 @@ export function createGovernedWaitEventObservation( resolvePending: () => Promise, ) { const pendingTools = new Set(); + let providerLost = false; let generation = 0; let observation: { sourceInstanceId: string; @@ -1261,6 +1263,7 @@ export function createGovernedWaitEventObservation( const currentGeneration = ++generation; observation = null; const payload = record(event.payload); + providerLost ||= event.eventType === "runtime_request.expired" && payload.reason === "provider_process_lost"; const kind = payload.kind; const tool = ["dynamicToolCall", "mcpToolCall", "commandExecution"].includes(String(kind)); if (event.itemId) { @@ -1276,7 +1279,9 @@ export function createGovernedWaitEventObservation( if (event.eventType === "item.completed" && ( pendingTools.size > 0 || (!tool && !(kind === "agentMessage" && payload.channel === "final")) )) return; - if (!eligible) return; + // A replacement question preserves human input after a provider crash; + // it does not authorize a successful suspension of the failed execution. + if (providerLost || !eligible) return; const result = await resolvePending(); if (generation !== currentGeneration || result === null) return; // If the interaction is answered after this read, parking remains the @@ -1656,8 +1661,8 @@ function nativeSessionKey(execution: NativeExecutionInput): string { ); } -function nativeSessionWorkspaceScope(execution: NativeExecutionInput) { - if (execution.provider.kind === "openai_dot") return { kind: "none" as const }; +export function nativeSessionWorkspaceScope(execution: { binding: Pick; workspace: NativeExecutionInput["workspace"] }) { + if ("access" in execution.workspace) return { kind: "none" as const }; // Projectless local runs use the heartbeat run id as a durable placeholder // rather than fabricating an execution_workspaces row. Do not let that // per-run placeholder break continuity for the same provider session; the @@ -5623,6 +5628,7 @@ export function providerSessionIdentityFromDurableProviderState(input: { identity.effectiveModel !== expectedModel || identity.permissionMode !== input.execution.provider.permissionMode || !acpxRecoveryModeMatches(input.execution.provider, descriptor.mode, identity.mode) || + !acpxRecoveryPiThinkingMatches(input.execution.provider, descriptor.piThinkingLevel, identity.piThinkingLevel) || !["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes( String(identity.permissionMode), ) || @@ -5730,6 +5736,12 @@ function acpxRecoveryModeMatches( && observedModes.every(mode => mode === expected); } +function acpxRecoveryPiThinkingMatches(provider: NativeExecutionInput["provider"], ...observed: unknown[]): boolean { + const expected = record(provider).piThinkingLevel; + if (provider.kind === "acpx" && provider.agent === "pi") return ["off", "low", "high", "max"].includes(String(expected)) && observed.every(value => value === expected); + return expected === undefined && observed.every(value => value === undefined); +} + export function providerSessionIdentityTransitionIsAllowed(input: { execution: NativeExecutionInput; previous: unknown; @@ -5740,6 +5752,7 @@ export function providerSessionIdentityTransitionIsAllowed(input: { record(record(input.previous).providerSessionIdentity).mode, record(record(input.current).providerSessionIdentity).mode, )) return false; + if (!acpxRecoveryPiThinkingMatches(input.execution.provider, record(record(input.previous).providerSessionIdentity).piThinkingLevel, record(record(input.current).providerSessionIdentity).piThinkingLevel)) return false; if (canonicalJson(input.previous) === canonicalJson(input.current)) { return true; } @@ -7651,7 +7664,7 @@ async function executePaperclipNativeSessionWithinScope( } if ( input.execution.provider.kind === "acpx" && - ["pi", "copilot"].includes(input.execution.provider.agent) && + ["copilot"].includes(input.execution.provider.agent) && !resolveAcpxQualification(input.execution.provider, process.env) ) { throw new Error( @@ -9890,7 +9903,7 @@ type RemoteProviderPackManifest = { distDigest: string; bridgeDigest: string; acpxProfileDigests: typeof REMOTE_PROVIDER_PACK_PROFILE_DIGESTS; - providers?: Partial>; @@ -10079,9 +10092,10 @@ function readRemoteProviderPackIdentity(packRoot: string, verifyControllerFiles: } for (const [provider, candidate] of Object.entries(candidates)) { const expectedPath = `provider-assets/${provider}/${payload.target.platform}-${payload.target.architecture}`; - if (!(inventory === "providers" ? ["cursor"] : ["cursor", "copilot", "pi"]).includes(provider) || !candidate + if (!(inventory === "providers" ? ["pi", "cursor"] : ["cursor", "copilot", "pi"]).includes(provider) || !candidate || Object.keys(candidate).some(key => !["version", "profileDigest", "closureDigest", "qualification", "path", "sha256"].includes(key)) - || candidate.qualification !== (provider === "cursor" ? "qualified" : "pending") || candidate.path !== expectedPath + || candidate.qualification !== (["pi", "cursor"].includes(provider) ? "qualified" : "pending") + || candidate.path !== expectedPath || typeof candidate.version !== "string" || !candidate.version || candidate.version.length > 120 || !/^sha256:[a-f0-9]{64}$/.test(candidate.profileDigest) || !/^sha256:[a-f0-9]{64}$/.test(candidate.closureDigest) @@ -11348,8 +11362,16 @@ async function createRunnerdBackendWithinSessionClaim( remoteTarget !== null && (input.execution.provider.kind === "opencode" || input.execution.provider.kind === "acpx"); + // Pi's explicit operator import supplies the target-platform authority. Never + // substitute a Mac controller daemon or trust an image's self-reported hashes. + // Existing explicit overrides and every other provider keep their old path. + const remotePiCompanion = await selectRemotePiCompanion({ + provider: input.execution.provider, remote: remoteTarget !== null, + binaryOverride: input.runnerRemoteBinaryPath, packOverride: input.runnerRemoteProviderPackPath, + workspaceRoot: input.execution.workspace.cwd, + }); const configuredProviderPackRoot = - input.runnerRemoteProviderPackPath?.trim() || null; + input.runnerRemoteProviderPackPath?.trim() || remotePiCompanion?.providerPack || null; let expectedProviderPackManifest: RemoteProviderPackManifest | null = null; const useBundledCursorImageAssets = requiresRemoteProviderPack && !configuredProviderPackRoot && input.execution.provider.kind === "acpx" && input.execution.provider.agent === "cursor"; @@ -11382,7 +11404,7 @@ async function createRunnerdBackendWithinSessionClaim( // When an explicit remote artifact is configured, prepareRemoteRunner stages // these exact bytes at remoteBinary before launch. const controllerRunnerBinary = remoteTarget - ? input.runnerRemoteBinaryPath?.trim() || (useBundledCursorImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary()) + ? input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledCursorImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary()) : resolvePaperclipRunnerBinary(); const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null; const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null; @@ -11761,7 +11783,7 @@ async function createRunnerdBackendWithinSessionClaim( if (!existsSync(sourceBinary)) { throw new Error("runner_remote_artifact_unavailable"); } - if (!explicitRemoteBinary) { + if (!explicitRemoteBinary && !remotePiCompanion) { const platform = await remoteCommandRunner.execute({ command: "sh", args: ["-c", "uname -s; uname -m"], @@ -13019,6 +13041,7 @@ async function createRunnerdBackendWithinSessionClaim( : resolveAcpxQualification(input.execution.provider, process.env), acpxPermissionMode: input.execution.provider.permissionMode, acpxMode: input.execution.provider.mode, + piThinkingLevel: input.execution.provider.piThinkingLevel, acpxPermissionModePinned: input.execution.schema === "paperclip.native-execution-input.v4" || input.execution.schema === "paperclip.native-execution-input.v5", diff --git a/server/src/services/native-runtime/native-session-resume.test.ts b/server/src/services/native-runtime/native-session-resume.test.ts index 574e0dda15..41b99c90bb 100644 --- a/server/src/services/native-runtime/native-session-resume.test.ts +++ b/server/src/services/native-runtime/native-session-resume.test.ts @@ -734,6 +734,10 @@ const recoveryFakeCodex = resolve( const previousStateBase = process.env.PAPERCLIP_RUNNER_STATE_DIR; const server = createServer(); let firstSession: NativeSession | undefined; + const ownedSessions = new Set(); + const ownedSpawns: Array<{ pid: number; processGroupId: number | null; startedAt: string }> = []; + const onSpawn = async (meta: typeof ownedSpawns[number]) => { ownedSpawns.push(meta); }; + let executionCloseCompleted = false; const runnerDiagnostics: string[] = []; const onRunnerLog = async (_stream: "stdout" | "stderr", chunk: string) => { runnerDiagnostics.push(chunk.slice(-4_096)); @@ -860,6 +864,7 @@ const recoveryFakeCodex = resolve( runnerInstanceId, runnerEnvironment: environment, onLog: onRunnerLog, + onSpawn, }); firstSession = await firstBackend.openSession({ identity: { @@ -871,6 +876,7 @@ const recoveryFakeCodex = resolve( }, workingDirectory: workspace, }); + ownedSessions.add(firstSession); const checkpoint = await firstSession.snapshot(); expect(checkpoint.identity).toEqual({ companyId, @@ -1009,6 +1015,7 @@ const recoveryFakeCodex = resolve( runnerInstanceId, runnerEnvironment: environment, onLog: onRunnerLog, + onSpawn, }); let continuity: Record | undefined; const controlPlaneInstanceId = randomUUID(); @@ -1031,6 +1038,12 @@ const recoveryFakeCodex = resolve( runnerInstanceId, controlPlaneInstanceId, timeoutMs: 20_000, + // This disposable real-daemon fixture must join full retirement before + // removing its controller routes and durable state. The production + // default deliberately permits asynchronous cleanup after a result. + requireSessionCloseBeforeReturn: true, + onSession(value) { if (value) ownedSessions.add(value); }, + async onSessionClosed() { executionCloseCompleted = true; }, controlPlane: port, async onContinuityBreak(value) { continuity = value; @@ -1064,6 +1077,12 @@ const recoveryFakeCodex = resolve( terminal: { runTerminalState: "succeeded" }, normalizedSessionId, }); + expect(executionCloseCompleted).toBe(true); + expect(ownedSpawns.length).toBeGreaterThanOrEqual(2); + for (const { pid } of ownedSpawns) { + expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" })); + } + console.info("Recovery fixture retired owned daemons", ownedSpawns); expect(continuity).toMatchObject({ // The daemon can reject the damaged retained input during startup, // before attach gets a chance to reject the unsettled provider session. @@ -1201,9 +1220,13 @@ const recoveryFakeCodex = resolve( .where(eq(issues.id, issueId)); expect(task).toEqual({ id: issueId, assigneeAgentId: agentId }); } finally { - await firstSession - ?.close({ reason: "Recovery fixture cleanup" }) - .catch(() => undefined); + // onSession(null) quarantines the runtime owner before close finishes; + // keep every published handle so an assertion/error cannot lose cleanup. + if (firstSession) ownedSessions.add(firstSession); + const closed = await Promise.allSettled([...ownedSessions].map((session) => + Promise.resolve().then(() => session.close({ reason: "Recovery fixture cleanup" })), + )); + const closeFailures = closed.filter((result) => result.status === "rejected"); runnerPrpWebSocketInternals.resetForTests(); server.closeAllConnections(); await new Promise((done) => server.close(() => done())); @@ -1211,6 +1234,10 @@ const recoveryFakeCodex = resolve( delete process.env.PAPERCLIP_RUNNER_STATE_DIR; else process.env.PAPERCLIP_RUNNER_STATE_DIR = previousStateBase; await database.cleanup(); + if (closeFailures.length) { + // Retain exact fixture state if owned retirement cannot be proven. + throw new AggregateError(closeFailures.map((result) => result.reason), "Recovery fixture session cleanup failed"); + } await rm(scratch, { recursive: true, force: true }); } }, diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index 114963a68a..121ff98dc2 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -5,6 +5,7 @@ import { PAPERCLIP_RUNNER_ACPX_PROFILES, resolvePaperclipRunnerPermissionMode, resolvePaperclipRunnerCursorMode, + resolvePaperclipRunnerPiThinkingLevel, type PaperclipRunnerProvider, } from "@paperclipai/adapter-utils"; import { @@ -29,6 +30,7 @@ export const DEFAULT_ACPX_RUNNER_MODELS = { // These profiles require explicit configuration. Admission belongs to the runner. codex: null, cursor: null, + pi: null, } as const; export type QualifiedPaperclipRunnerAcpxAgent = @@ -127,6 +129,7 @@ export type PaperclipRunnerNativeProviderInput = acpxAgent: AdmittedPaperclipRunnerAcpxAgent; acpxPermissionMode: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; acpxSessionMode?: "agent" | "plan" | "ask"; + piThinkingLevel?: "off" | "low" | "high" | "max"; }; export class PaperclipRunnerProviderProfileError extends Error { @@ -376,10 +379,13 @@ export function resolvePaperclipRunnerProviderProfile( resolvePaperclipRunnerCursorMode(candidate, config.acpxAgent, config.acpxSessionMode); } catch (error) { throw new PaperclipRunnerProviderProfileError( - "paperclip_runner_cursor_mode_invalid", + "paperclip_runner_mode_invalid", error instanceof Error ? error.message : "Invalid Cursor session mode", ); } + try { resolvePaperclipRunnerPiThinkingLevel(candidate, config.acpxAgent, config.piThinkingLevel); } catch (error) { + throw new PaperclipRunnerProviderProfileError("paperclip_runner_pi_thinking_invalid", error instanceof Error ? error.message : "Invalid Pi thinking level"); + } const model = optionalString(config.model); if (candidate === "codex") { return { @@ -486,10 +492,10 @@ export function resolvePaperclipRunnerProviderProfile( } throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_agent_unavailable", `${pendingAcpxProfile.label} is awaiting local and Daytona qualification. Its profile is not enabled for production runs.`); } - if (acpxAgent === "cursor" && !model) { - throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", "Cursor requires an explicit model ID; there is no default model."); + if ((acpxAgent === "cursor" || acpxAgent === "pi") && !model) { + throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", "This ACPX agent requires an explicit model ID; there is no default model."); } - if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok" && acpxAgent !== "cursor") { + if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok" && acpxAgent !== "cursor" && acpxAgent !== "pi") { throw new PaperclipRunnerProviderProfileError( "paperclip_runner_acpx_agent_unavailable", "Paperclip Runner ACPX requires a qualified agent profile.", @@ -567,6 +573,7 @@ export function resolvePaperclipRunnerNativeProviderInput(input: { provider: "acpx", model: profile.model, acpxAgent: profile.acpxAgent, + ...(profile.acpxAgent === "pi" ? { piThinkingLevel: resolvePaperclipRunnerPiThinkingLevel("acpx", "pi", config.piThinkingLevel) } : {}), ...(profile.acpxAgent === "cursor" ? { acpxSessionMode: resolvePaperclipRunnerCursorMode("acpx", "cursor", config.acpxSessionMode), } : {}), diff --git a/server/src/services/native-runtime/remote-pi-companion.ts b/server/src/services/native-runtime/remote-pi-companion.ts new file mode 100644 index 0000000000..e13406b4b7 --- /dev/null +++ b/server/src/services/native-runtime/remote-pi-companion.ts @@ -0,0 +1,162 @@ +/** Explicit operator-imported Linux authority. No downloads or executable probes. */ +import { setImmediate as yieldToSignals } from "node:timers/promises"; +import { createHash } from "node:crypto"; +import { type Stats, constants } from "node:fs"; +import * as fs from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { QUALIFIED_ACPX_PROFILES } from "../../vendor/paperclip-runner/index.js"; + +const SERVER_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const MAX_BYTES = 4 * 1024 ** 3; +const MAX_FILE = 512 * 1024 ** 2; +const MANIFEST = "companion.json"; +const AUTHORITY = ".import-authority.json"; +type Entry = { path: string; mode: number; kind: "directory" } | { path: string; mode: number; kind: "file"; size: number; sha256: string } | { path: string; mode: number; kind: "symlink"; target: string }; +export interface RemotePiCompanionManifest { schema: "paperclip.remote-pi-companion/v1"; sourceRevision: string; target: "linux-x64"; profileDigest: string; providerPackDigest: string; daemonSha256: string; entries: Entry[] } +function require(value: unknown, reason: string): asserts value { if (!value) throw new Error(`runner_remote_companion_invalid: ${reason}`); } +const digest = (value: Buffer | string) => createHash("sha256").update(value).digest("hex"); +const safe = (path: string) => path.length > 0 && path.length < 4096 && !isAbsolute(path) && !/[\\\x00-\x1f\x7f]/u.test(path) && path.split("/").every(p => p !== "" && p !== "." && p !== ".."); +const inside = (root: string, path: string) => path === root || (!relative(root, path).startsWith("..") && !isAbsolute(relative(root, path))); +const same = (a: Stats, b: Stats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mode === b.mode && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs && a.nlink === b.nlink; +type Checkpoint = () => Promise; +function checkpoints(cancel?: () => void): Checkpoint { + const deadline = Date.now() + 600_000; + return async () => { await yieldToSignals(); cancel?.(); require(Date.now() < deadline, "import deadline exceeded"); }; +} +async function directory(path: string) { const st = await fs.lstat(path); require(st.isDirectory() && !st.isSymbolicLink() && await fs.realpath(path) === path, "directory is linked or noncanonical"); return st; } +/** One descriptor and 1MiB buffer; no whole executable allocation or sync hashing. */ +async function readStable(path: string, maximum: number, privateFile: boolean, check: Checkpoint, consume?: (chunk: Buffer) => Promise) { + require(await fs.realpath(dirname(path)) === dirname(path), "linked parent"); + const file = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const before = await file.stat(); require(before.isFile() && before.size <= maximum && (!privateFile || before.nlink === 1), "file type, link or byte bound"); + const hash = createHash("sha256"); let size = 0; const buffer = Buffer.alloc(1024 * 1024); let header = Buffer.alloc(0); + for (;;) { + await check(); const { bytesRead } = await file.read(buffer); if (!bytesRead) break; + size += bytesRead; require(size <= maximum && size <= before.size, "file grew beyond bound"); + const chunk = buffer.subarray(0, bytesRead); hash.update(chunk); if (!header.length) header = Buffer.from(chunk.subarray(0, 64)); + await consume?.(chunk); + } + require(size === before.size && same(before, await file.stat()) && same(before, await fs.lstat(path)), "file changed during read"); + return { size, sha256: hash.digest("hex"), header, stat: before }; + } finally { await file.close(); } +} +async function readOwned(path: string, maximum: number, check: Checkpoint, privateFile = false): Promise { + const chunks: Buffer[] = []; await readStable(path, maximum, privateFile, check, async chunk => { chunks.push(Buffer.from(chunk)); }); return Buffer.concat(chunks); +} +/** Complete no-follow inventory, including modes and contained symbolic links. */ +export async function inventoryRemoteCompanion(root: string, check = checkpoints()): Promise { + await directory(root); const entries: Entry[] = []; const links = new Map(); let total = 0; + const visit = async (path: string): Promise => { + const before = await directory(path); + for (const name of (await fs.readdir(path)).sort()) { + await check(); + if (path === root && [MANIFEST, AUTHORITY].includes(name)) continue; + const file = join(path, name); const rel = relative(root, file); require(safe(rel), "unsafe path"); + const st = await fs.lstat(file); const mode = st.mode & 0o7777; + require(entries.length < 100_000 && (st.isSymbolicLink() || (mode & 0o7022) === 0), "entry count or unsafe mode"); + if (st.isDirectory()) { require((mode & 0o700) === 0o700, "directory requires owner read/write/search for owned cleanup"); entries.push({ path: rel, mode, kind: "directory" }); await visit(file); } + else if (st.isFile()) { + total += st.size; require(total <= MAX_BYTES, "tree byte bound"); const read = await readStable(file, MAX_FILE, false, check); + require(same(st, read.stat), "discovered file changed"); + entries.push({ path: rel, mode, kind: "file", size: read.size, sha256: read.sha256 }); + const key = `${st.dev}:${st.ino}`; const group = links.get(key) ?? { count: 0, links: st.nlink }; group.count++; require(group.links === st.nlink, "hardlink identity drift"); links.set(key, group); + } else if (st.isSymbolicLink()) { + const target = await fs.readlink(file); require(!isAbsolute(target) && !/[\x00-\x1f\x7f]/u.test(target) && inside(root, resolve(dirname(file), target)) && inside(root, await fs.realpath(file)), "escaping symbolic link"); + require(same(st, await fs.lstat(file)), "link changed"); entries.push({ path: rel, mode, kind: "symlink", target }); + } else throw new Error("runner_remote_companion_invalid: special entry"); + } + const after = await directory(path); require(before.dev === after.dev && before.ino === after.ino && before.mtimeMs === after.mtimeMs, "directory changed"); + }; + await visit(root); require([...links.values()].every(g => g.count === g.links), "external hardlink"); + return entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); +} +async function installedIdentity(serverRoot: string, check: Checkpoint) { + require(process.platform === "darwin" || process.platform === "linux", "companion import supports macOS and Linux controllers"); + await directory(serverRoot); const pkg = JSON.parse((await readOwned(join(serverRoot, "package.json"), 65536, check, true)).toString()); require(pkg.name === "@paperclipai/server", "public server package required"); + const source = JSON.parse((await readOwned(join(serverRoot, "dist/build-info.json"), 65536, check, true)).toString()).commit; + require(typeof source === "string" && /^[a-f0-9]{40}$/u.test(source), "installed build source is missing"); + const profile = QUALIFIED_ACPX_PROFILES.pi; require(profile.qualificationStatus !== "pending", "Pi is not qualified"); + return { source, profileDigest: profile.commandDigest }; +} +async function validate(root: string, manifest: RemotePiCompanionManifest, identity: Awaited>, check: Checkpoint) { + require(manifest.schema === "paperclip.remote-pi-companion/v1" && manifest.target === "linux-x64" && manifest.sourceRevision === identity.source && manifest.profileDigest === identity.profileDigest, "source/profile/target mismatch"); + require(JSON.stringify(await inventoryRemoteCompanion(root, check)) === JSON.stringify(manifest.entries), "complete inventory mismatch"); + const daemon = manifest.entries.find(e => e.path === "bin/paperclip-runnerd"); + require(daemon?.kind === "file" && daemon.size > 64 && (daemon.mode & 0o111) !== 0 && daemon.sha256 === manifest.daemonSha256, "daemon identity"); + const elf = (await readStable(join(root, daemon.path), MAX_FILE, false, check)).header; require(elf.subarray(0, 4).equals(Buffer.from([127, 69, 76, 70])) && elf[4] === 2 && elf[5] === 1 && elf.readUInt16LE(18) === 62, "Linux x64 ELF required"); + const pack = JSON.parse((await readOwned(join(root, "provider-pack/provider-pack.json"), 16 * 1024 ** 2, check)).toString()); + const canonical = (v: unknown): string => Array.isArray(v) ? `[${v.map(canonical).join(",")}]` : v && typeof v === "object" ? `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${canonical((v as Record)[k])}`).join(",")}}` : JSON.stringify(v); + require(pack.schema === "paperclip-runner/remote-provider-pack/v1" && pack.digest === manifest.providerPackDigest && pack.digest === `sha256:${digest(canonical(pack.payload))}`, "pack manifest digest"); + require(pack.payload.runnerSourceRevision === identity.source && pack.payload.target.platform === "linux" && pack.payload.target.architecture === "x64", "pack source/target"); + const pi = pack.payload.candidateProviders?.pi; require(pi?.qualification === "qualified" && pi.profileDigest === identity.profileDigest && pi.path === "provider-assets/pi/linux-x64", "normal Pi pack required"); +} +/** Release-side command uses this same inventory contract before publication. */ +export async function createRemotePiCompanionManifest(root: string, sourceRevision: string): Promise { + const check = checkpoints(); + const profile = QUALIFIED_ACPX_PROFILES.pi; const pack = JSON.parse((await readOwned(join(root, "provider-pack/provider-pack.json"), 16 * 1024 ** 2, check)).toString()); const entries = await inventoryRemoteCompanion(root, check); + const daemon = entries.find(e => e.path === "bin/paperclip-runnerd"); require(daemon?.kind === "file", "daemon missing"); + const manifest: RemotePiCompanionManifest = { schema: "paperclip.remote-pi-companion/v1", sourceRevision, target: "linux-x64", profileDigest: profile.commandDigest, providerPackDigest: pack.digest, daemonSha256: daemon.sha256, entries }; + require(/^[a-f0-9]{40}$/u.test(sourceRevision), "release source"); await validate(root, manifest, { source: sourceRevision, profileDigest: profile.commandDigest }, check); return manifest; +} +function cacheParent(serverRoot: string) { return join(serverRoot, "remote-companions"); } +async function removeOwned(path: string, owned: Stats) { const st = await fs.lstat(path); require(st.dev === owned.dev && st.ino === owned.ino && !st.isSymbolicLink(), "cleanup root ownership changed"); await fs.rm(path, { recursive: true }); } +export async function importRemotePiCompanion(input: { directory: string; sha256: string; serverRoot?: string; checkCancelled?: () => void }) { + const checkpoint = checkpoints(input.checkCancelled); + await checkpoint(); + const serverRoot = input.serverRoot ?? SERVER_ROOT; const identity = await installedIdentity(serverRoot, checkpoint); const source = await fs.realpath(input.directory); require(source === resolve(input.directory), "linked import root"); await directory(source); + require(/^[a-f0-9]{64}$/u.test(input.sha256), "expected manifest SHA256 required"); const bytes = await readOwned(join(source, MANIFEST), 32 * 1024 ** 2, checkpoint); require(digest(bytes) === input.sha256, "operator manifest digest mismatch"); + const manifest = JSON.parse(bytes.toString()) as RemotePiCompanionManifest; await validate(source, manifest, identity, checkpoint); await checkpoint(); + const parent = cacheParent(serverRoot); try { await fs.mkdir(parent, { mode: 0o700 }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw new Error("Remote companion setup requires a writable installed server package", { cause: error }); } + const parentInfo = await directory(parent); require((parentInfo.mode & 0o077) === 0 && parentInfo.uid === process.getuid?.(), "cache must be private and operator-owned"); + const lockPath = join(parent, ".import-linux-x64.lock"); const lock = await fs.open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); const lockInfo = await lock.stat(); + let staging: string | undefined; let stagingInfo: Stats | undefined; let outputInfo: Stats | undefined; let committed = false; const output = join(parent, "linux-x64"); + try { + await checkpoint(); + try { await fs.lstat(output); const existing = await resolveRemotePiCompanion({ serverRoot, checkpoint }); require(existing?.manifestSha256 === input.sha256, "existing companion differs; remove only after stopping all runs"); await checkpoint(); return { status: "verified_existing", ...existing }; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + staging = await fs.mkdtemp(join(parent, ".import-")); stagingInfo = await fs.lstat(staging); + for (const entry of manifest.entries.filter(e => e.kind === "directory").sort((a, b) => a.path.split("/").length - b.path.split("/").length)) await fs.mkdir(join(staging, entry.path), { mode: 0o700 }); + for (const entry of manifest.entries) { + if (entry.kind !== "file") continue; + const file = join(staging, entry.path); const outputFile = await fs.open(file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); + try { + const read = await readStable(join(source, entry.path), MAX_FILE, false, checkpoint, async chunk => { + let offset = 0; while (offset < chunk.length) { await checkpoint(); offset += (await outputFile.write(chunk, offset)).bytesWritten; } + }); + require(read.size === entry.size && read.sha256 === entry.sha256, "source changed while copied"); + } finally { await outputFile.close(); } + await fs.chmod(file, entry.mode); await checkpoint(); + } + for (const entry of manifest.entries) if (entry.kind === "symlink") await fs.symlink(entry.target, join(staging, entry.path)); + for (const entry of manifest.entries.filter(e => e.kind === "directory").reverse()) await fs.chmod(join(staging, entry.path), entry.mode); + await fs.writeFile(join(staging, MANIFEST), bytes, { flag: "wx", mode: 0o600 }); await validate(staging, manifest, identity, checkpoint); await validate(source, manifest, identity, checkpoint); await checkpoint(); + // Claim the final path exclusively; no rename may replace a concurrent directory. + await fs.mkdir(output, { mode: 0o700 }); outputInfo = await fs.lstat(output); + for (const name of await fs.readdir(staging)) { const now = await directory(output); require(now.dev === outputInfo.dev && now.ino === outputInfo.ino, "publication ownership changed"); await fs.rename(join(staging, name), join(output, name)); } + await fs.writeFile(join(output, AUTHORITY), JSON.stringify({ sha256: input.sha256 }) + "\n", { flag: "wx", mode: 0o600 }); + const result = await resolveRemotePiCompanion({ serverRoot, checkpoint }); require(result?.manifestSha256 === input.sha256, "publication verification"); await checkpoint(); committed = true; return { status: "imported_verified", ...result }; + } finally { + const failures: unknown[] = []; const cleanup = async (fn: () => Promise) => { try { await fn(); } catch (error) { failures.push(error); } }; + if (outputInfo && !committed) await cleanup(() => removeOwned(output, outputInfo!)); + if (staging && stagingInfo) await cleanup(() => removeOwned(staging!, stagingInfo!)); + await cleanup(async () => { const now = await fs.lstat(lockPath); require(now.dev === lockInfo.dev && now.ino === lockInfo.ino, "lock ownership changed"); await fs.unlink(lockPath); }); await lock.close(); + if (failures.length) throw new AggregateError(failures, "Remote companion cleanup incomplete; inspect owned paths before retrying"); + } +} +export async function resolveRemotePiCompanion(input: { serverRoot?: string; workspaceRoot?: string; checkpoint?: Checkpoint } = {}) { + const check = input.checkpoint ?? checkpoints(); + const serverRoot = input.serverRoot ?? SERVER_ROOT; const parent = cacheParent(serverRoot); const root = join(parent, "linux-x64"); + try { await fs.lstat(root); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } + const parentInfo = await directory(parent); require((parentInfo.mode & 0o077) === 0 && parentInfo.uid === process.getuid?.(), "cache privacy"); await directory(root); + if (input.workspaceRoot) { const workspace = await fs.realpath(input.workspaceRoot); require(!inside(workspace, root) && !inside(root, workspace), "companion cache cannot overlap a workspace"); } + const authority = JSON.parse((await readOwned(join(root, AUTHORITY), 65536, check, true)).toString()); const bytes = await readOwned(join(root, MANIFEST), 32 * 1024 ** 2, check, true); require(digest(bytes) === authority.sha256, "installed authority changed"); + const manifest = JSON.parse(bytes.toString()) as RemotePiCompanionManifest; await validate(root, manifest, await installedIdentity(serverRoot, check), check); + return { root, runnerBinary: join(root, "bin/paperclip-runnerd"), providerPack: join(root, "provider-pack"), manifestSha256: authority.sha256 as string, sourceRevision: manifest.sourceRevision, target: manifest.target }; +} + +/** Explicit legacy overrides remain authoritative; C/C never gain this Pi path. */ +export async function selectRemotePiCompanion(input: { provider: { kind: string; agent?: string }; remote: boolean; binaryOverride?: string | null; packOverride?: string | null; workspaceRoot: string; serverRoot?: string }) { + if (!input.remote || input.provider.kind !== "acpx" || input.provider.agent !== "pi" || input.binaryOverride?.trim() || input.packOverride?.trim()) return null; + return resolveRemotePiCompanion({ serverRoot: input.serverRoot, workspaceRoot: input.workspaceRoot }); +} diff --git a/server/src/services/native-runtime/status-arbiter.test.ts b/server/src/services/native-runtime/status-arbiter.test.ts index 2e77bb7e41..3a31bb6b99 100644 --- a/server/src/services/native-runtime/status-arbiter.test.ts +++ b/server/src/services/native-runtime/status-arbiter.test.ts @@ -44,71 +44,27 @@ function arbitrate( } describe("native status authority", () => { - it("waits on persisted monitors without an immediate continuation, even with unfinished work", () => { - const pending = assessment({ reportedDisposition: "yielded", hasBlockingRemainingWork: true, - continuation: { kind: "monitor", summary: "Check CI", idempotencyKey: "ci" } }); - for (const priorIssueStatus of ["in_progress", "in_review"] as const) { - expect(arbitrate({ assessment: pending, priorIssueStatus, monitorWaitAuthorized: true })) - .toMatchObject({ statusAction: "preserve", toStatus: priorIssueStatus, - reasonCode: "scheduled_monitor_waiting", effects: [{ kind: "release_checkout" }] }); - } - expect(arbitrate({ assessment: pending })).toMatchObject({ reasonCode: "monitor_wait_authority_lost", - effects: [{ kind: "record_finalization_error" }] }); - expect(arbitrate({ assessment: pending, monitorWaitAuthorized: true, hasUnresolvedIssueBlockers: true }).toStatus).toBe("blocked"); - expect(arbitrate({ assessment: pending, monitorWaitAuthorized: true, governanceGate: { kind: "approval", id: "pending" } }).toStatus).toBe("in_review"); - }); - - it("keeps a pending child result non-terminal without adding another continuation", () => { - expect(arbitrate({ hasPendingChildCompletion: true })).toMatchObject({ - statusAction: "in_progress", toStatus: "in_progress", reasonCode: "native_child_completion_pending", - effects: [{ kind: "release_checkout" }], + it("keeps a verified accepted Cursor plan passive without completing or replaying work", () => { + const passive = assessment({ reportedDisposition: "yielded", objectiveSatisfied: false, + allCriteriaSatisfied: false, hasBlockingRemainingWork: true, + continuation: { kind: "response_wake", summary: "Explicit continuation needed", idempotencyKey: "cursor-plan-wait:event" } }); + expect(arbitrate({ assessment: passive, planWaitAuthorized: true })).toMatchObject({ + toStatus: "in_progress", reasonCode: "native_plan_accepted_waiting_for_continuation", effects: [], }); - expect(arbitrate({ hasPendingChildCompletion: false }).toStatus).toBe("done"); - for (const priorIssueStatus of ["done", "cancelled"] as const) { - expect(arbitrate({ priorIssueStatus, hasPendingChildCompletion: true }).toStatus).toBe(priorIssueStatus); - } - expect(arbitrate({ hasPendingChildCompletion: true, hasUnresolvedIssueBlockers: true }).toStatus).toBe("blocked"); - expect(arbitrate({ hasPendingChildCompletion: true, governanceGate: { kind: "approval", id: "approval" } }).toStatus) - .toBe("in_review"); - expect(arbitrate({ hasPendingChildCompletion: true, workspaceFinalizeStatus: "failed" }).statusAction).toBe("preserve"); + expect(arbitrate({ assessment: passive })).toMatchObject({ + toStatus: "in_progress", + reasonCode: "completion_evidence_incomplete", + effects: [expect.objectContaining({ + kind: "enqueue_continuation", + continuationKind: "same_agent", + idempotencyKey: "native-completion-incomplete", + })], + }); + expect(arbitrate({ assessment: passive, planWaitAuthorized: true, terminalState: "failed" }).reasonCode).not.toBe("native_plan_accepted_waiting_for_continuation"); + expect(arbitrate({ assessment: passive, planWaitAuthorized: true, priorIssueStatus: "cancelled" }).toStatus).toBe("cancelled"); + expect(arbitrate({ assessment: passive, planWaitAuthorized: true, governanceGate: { kind: "interaction", id: "pending" } }).toStatus).toBe("in_review"); }); - it("schedules a capacity retry while preserving partial work and review authority", () => { - for (const nativeReviewOutcome of [undefined, "pending"] as const) { - const decision = arbitrate({ terminalState: "failed", providerOverloaded: true, nativeReviewOutcome }); - expect(decision).toMatchObject({ statusAction: "preserve", reasonCode: "native_provider_overloaded" }); - expect(decision.effects).toContainEqual(expect.objectContaining({ kind: "schedule_retry", cause: "native_provider_overloaded" })); - } - expect(arbitrate({ terminalState: "failed", providerOverloaded: true, failureRetryCount: 2 })) - .toMatchObject({ statusAction: "blocked", reasonCode: "native_provider_overloaded_exhausted", effects: [{ kind: "bind_blocker", owner: "board", action: expect.stringContaining("Automatic retries exhausted") }] }); - }); - it.each([ - { hasActivePauseHold: true }, { hasUnresolvedIssueBlockers: true }, - { governanceGate: { kind: "approval" as const, id: "approval" } }, - { priorIssueStatus: "blocked" as const }, { priorIssueStatus: "done" as const }, - { workspaceFinalizeStatus: "failed" as const }, { nativeReviewOutcome: "stale" as const }, - { nativeReviewOutcome: "resolved" as const }, - ])("does not schedule capacity retries through existing authority or cleanup gates (%j)", (gate) => { - const decision = arbitrate({ terminalState: "failed", providerOverloaded: true, ...gate }); - expect(decision.effects.some(effect => effect.kind === "schedule_retry")).toBe(false); - }); - it.each(["in_progress", "in_review"] as const)("blocks a current worker's proven model rejection without a retry (%s)", (priorIssueStatus) => { - const decision = arbitrate({ priorIssueStatus, terminalState: "failed", providerModelRejected: true }); - expect(decision).toMatchObject({ statusAction: "blocked", toStatus: "blocked", reasonCode: "native_provider_model_rejected", unblockDescriptor: { owner: "board" } }); - expect(decision.effects).toEqual([{ kind: "bind_blocker", owner: "board", action: expect.any(String) }]); - expect(arbitrate({ terminalState: "failed", providerModelRejected: false }).effects).toContainEqual(expect.objectContaining({ kind: "schedule_retry" })); - expect(arbitrate({ terminalState: "succeeded", providerModelRejected: true }).reasonCode).not.toBe("native_provider_model_rejected"); - expect(arbitrate({ terminalState: "failed", providerModelRejected: true, workspaceFinalizeStatus: "failed" }).reasonCode).toBe("finalization_failed_claim_preserved"); - expect(arbitrate({ terminalState: "failed", providerModelRejected: true, priorIssueStatus: "done" }).toStatus).toBe("done"); - for (const nativeReviewOutcome of ["stale", "resolved"] as const) { - expect(arbitrate({ terminalState: "failed", providerModelRejected: true, priorIssueStatus, nativeReviewOutcome })) - .toMatchObject({ statusAction: "preserve", toStatus: priorIssueStatus, reasonCode: "native_review_action_finished" }); - } - }); - it("preserves pending review authority without automatic recovery after model rejection", () => { - expect(arbitrate({ priorIssueStatus: "in_review", terminalState: "failed", providerModelRejected: true, nativeReviewOutcome: "pending" })) - .toMatchObject({ statusAction: "preserve", toStatus: "in_review", reasonCode: "native_provider_model_rejected", unblockDescriptor: null, effects: [{ kind: "release_checkout" }] }); - }); it("a reviewer finishes its decision without completing rejected or still-reviewed work", () => { for (const priorIssueStatus of ["in_progress", "in_review"] as const) { const decision = arbitrate({ priorIssueStatus, nativeReviewOutcome: "resolved" }); diff --git a/server/src/services/native-runtime/status-decision-committer.ts b/server/src/services/native-runtime/status-decision-committer.ts index 2f8c9babdd..6d60ac6143 100644 --- a/server/src/services/native-runtime/status-decision-committer.ts +++ b/server/src/services/native-runtime/status-decision-committer.ts @@ -1699,32 +1699,6 @@ export async function commitNativeStatusDecision(input: { throw new NativeStatusRaceError(); } } - let cursorPlanWait: Awaited> = 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; - } - } const passiveBoardResponseWait = reasonCode === "board_response_waiting" || reasonCode === "board_response_wait_superseded"; diff --git a/server/src/services/public-mcp/contracts.ts b/server/src/services/public-mcp/contracts.ts index 1288399c5d..ead3d81261 100644 --- a/server/src/services/public-mcp/contracts.ts +++ b/server/src/services/public-mcp/contracts.ts @@ -39,4 +39,3 @@ export type Capability = { name: string; description: string; schema: z.ZodObject; write?: boolean; configure?: boolean; destructive?: boolean; ephemeral?: boolean; run: (p: McpPrincipal, args: Record, api: ApiDispatch, origin: string, transfers?: PublicMcpTransfers) => Promise>; }; - diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 3f0c165a47..ff2de0c336 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -1,4 +1,3 @@ -import { hasCommittedNativeCursorPlanWait } from "../native-runtime/native-cursor-plan-wait.js"; import { isAiAuthenticationBlocked } from "../ai-auth-failure.js"; import { hasCommittedNativePlanWait } from "../native-runtime/native-plan-wait.js"; import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared"; diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index bfab10f1e2..13165d07c9 100644 --- a/server/src/vendor/paperclip-runner/index.ts +++ b/server/src/vendor/paperclip-runner/index.ts @@ -28,6 +28,7 @@ export type { ControlPlanePort, HarnessRuntimeRequestKind, HarnessRuntimeRequestResolution, + LinuxProcessStartOptions, NativeAcpxAgent, NativeAcpxPermissionMode, NativeCodexApprovalPolicy, @@ -80,6 +81,7 @@ export const DurablePrpControlPlane = runner.DurablePrpControlPlane; export const runnerCodexDynamicToolsFit = runner.runnerCodexDynamicToolsFit; export const inspectWarmRunTransition = runner.inspectWarmRunTransition; export const readRunnerdArtifactBinding = runner.readRunnerdArtifactBinding; +export const readLinuxProcessStartedAt = runner.readLinuxProcessStartedAt; export const NativeSessionCleanupQuarantinedError = runner.NativeSessionCleanupQuarantinedError; export const NativeSessionProtocolIntegrityError = @@ -143,10 +145,13 @@ export const nativeRestartInterruptedTurnId = runner.nativeRestartInterruptedTur export const completeTerminatedRemoteNativeSessionCleanup = runner.completeTerminatedRemoteNativeSessionCleanup; export const completeTerminatedLocalNativeSessionCleanup = runner.completeTerminatedLocalNativeSessionCleanup; -export const externalOperationDigest = runner.externalOperationDigest; +export const probeAcpxClaudeInstallation = runner.probeAcpxClaudeInstallation; +export const probeAcpxGrokInstallation = runner.probeAcpxGrokInstallation; +export const probeAcpxPiInstallation = runner.probeAcpxPiInstallation; export const bundledRemoteProviderPackManifestPath = runner.bundledRemoteProviderPackManifestPath; export const bundledRemoteRunnerBinary = runner.bundledRemoteRunnerBinary; +export const externalOperationDigest = runner.externalOperationDigest; export const CONFIGURED_ENVIRONMENT_KEYS = runner.CONFIGURED_ENVIRONMENT_KEYS; export const GENERATED_RUNTIME_ENVIRONMENT_KEYS = runner.GENERATED_RUNTIME_ENVIRONMENT_KEYS; export const configuredEnvironmentProjection = runner.configuredEnvironmentProjection; diff --git a/server/src/vendor/paperclip-runner/live/index.ts b/server/src/vendor/paperclip-runner/live/index.ts index a17ed6de1b..cbce8db42d 100644 --- a/server/src/vendor/paperclip-runner/live/index.ts +++ b/server/src/vendor/paperclip-runner/live/index.ts @@ -3,4 +3,5 @@ export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxCursorInstallation, + probeAcpxPiInstallation, } from "@paperclipai/paperclip-runner/live"; diff --git a/tests/acpx-qualification-models.ts b/tests/acpx-qualification-models.ts index 44452c47e8..ef4a978009 100644 --- a/tests/acpx-qualification-models.ts +++ b/tests/acpx-qualification-models.ts @@ -3,5 +3,5 @@ export const ACPX_QUALIFICATION_MODELS = { claude: "claude-sonnet-5", codex: "gpt-5.6-sol", grok: "grok-4.7", - pi: "openrouter/deepseek/deepseek-v4-flash-0731", + pi: "openrouter/anthropic/claude-sonnet-4.6", } as const; diff --git a/tests/e2e/chat-adapters-ui-messaging.spec.ts b/tests/e2e/chat-adapters-ui-messaging.spec.ts index de11726d12..cbd94d7bdf 100644 --- a/tests/e2e/chat-adapters-ui-messaging.spec.ts +++ b/tests/e2e/chat-adapters-ui-messaging.spec.ts @@ -1438,6 +1438,13 @@ test.describe("Exact failed chat run retry", () => { ? `/${seed.prefix}/agents/${seed.agentId}/runs/${failedRunId}` : `/${seed.prefix}/inbox/all`; await page.goto(startPath); + if (surface === "agent run") { + // Canonicalization reloads the agent query. Interact after that + // navigation so a remount cannot discard the retry mutation result. + await expect(page).toHaveURL( + new RegExp(`/${seed.prefix}/agents/maya/runs/${failedRunId}$`), + ); + } const retry = page .getByRole("button", { name: "Retry", exact: true }) .filter({ visible: true }); diff --git a/tests/runner-e2e/FIXTURES.md b/tests/runner-e2e/FIXTURES.md index 39ce906834..2e94cf78c4 100644 --- a/tests/runner-e2e/FIXTURES.md +++ b/tests/runner-e2e/FIXTURES.md @@ -31,6 +31,26 @@ profile, model qualification, environment, task, or ranking-snapshot change must change that fingerprint automatically so the dashboard can annotate the boundary instead of silently joining unlike totals. +Pi native definition 21 supplies one ordinary JSON write with the nonce followed +by exactly one LF, then asks for a complete native read. The independently +checked outcome remains 33 bytes, saved through the public managed-file API +and copied into a fresh task after controller restart. Missing LF, extra LF, +literal escapes, CRLF and stale values all fail. The cross-root denial, protected +parent seed, permissions, deadlines and zero automatic retries remain required. +Both runs also require a completed, untruncated native-read receipt containing +the exact memory text and a withheld private-root target, and reject shell +execution. Named workspace reads and unrelated/bootstrap text cannot substitute; +Remote observer admission keeps the owned-run and active-lease checks. Its +existing readiness RPC verifies the pinned native daemon before installation; +legacy executionStage can remain preparing for a native run. Readiness and case +deadlines remain unchanged. +The first Sonnet measurement passed the byte/restart checks but used a shell read +in the fresh task. Its original grade remains preserved. +The user approved Sonnet 4.6 through OpenRouter as the explicit Pi qualification +model; production model selection stays caller-controlled. Earlier DeepSeek +attempts and their fingerprints remain historical evidence and do not qualify +this new model/fixture combination. + The explicit [stock-harness suite](STOCK-HARNESS.md) wraps existing profiles with `productionDefaultHireProfile`: omit only `instructionsBundle` so the public hire route loads the shipped default, while preserving runtime, permissions, @@ -139,6 +159,14 @@ a deletion through public file APIs. Native turns 2 and 3 must copy/hash only th changed memory file, with a saved receipt and the same provider PID. Journal and Git stress fixtures retain fixed external bundles as controls. Keep the stable-PID oracle strict; `instruction-persistence` also covers cold restarts and quota handling. +The explicit `rich-acp-warm-continuity` fixture uses the workspace-only prompt: +ACP providers retain their unchanged AGENT_HOME and must preserve the same native +session, runner instance, provider session, PID, and process start fingerprint. +It does not request personal-file edits, because changed ACP agent files require +provider retirement before collection. Pi's separate `agent-files-fresh-run` +case retains changed-home save and fresh-task restoration coverage. This split +does not weaken the stable-process oracle or change the Codex checkpoint fixture. + Native turns 1 and 2 include an actionable human review in the completion report's `attentionRequests`. Paperclip creates the review gate from that report. An explicit question-tool wait yields the turn and suppresses its final prose, so it is not interchangeable with this completion-review fixture. Turn 3 reports Done without another review. Every selected case runs in its own isolated Paperclip process, and independent @@ -311,6 +339,13 @@ is explicit-only. No private control-plane hooks or direct database writes are u ## Pi native boundaries +Definition 23 and Pi controls definition 10 confirm `/proc/` absence +separately when a proc read fails during Linux process exit. An existing +unreadable process, an unreadable absence check, identity drift, and incomplete +terminal evidence still fail. The observer retains closed causes for process +reads, stat shape, runtime binding and terminal sealing; it never retains raw +process arguments. Historical failed receipts remain failed. + The explicit-only `pi-native` suite has five local and four Daytona candidate cells, with no automatic retries. The remote suite excludes automatic deny-all because its initial native file read is itself denied. `native-questions` answers the real runner-owned Pi select, confirm, input @@ -319,8 +354,13 @@ Undisclosed text and independent workspace JSON prove delivery to the same live Pi's SDK cannot distinguish negative confirmation from dismissal; the expected result is explicitly `negative_or_cancelled`, not proof of cancellation. -`agent-files-fresh-run` writes a hidden nonce through native file tools to the -registered AGENT_HOME, requires a stopped-run save receipt and public managed-file +`agent-files-fresh-run` supplies one native write argument object whose content +is the hidden nonce plus exactly one final LF. The agent replaces only the +AGENT_HOME prefix in the supplied path; it preserves the JSON newline escape in +the content argument, then reads the complete file once. A missing LF, literal +backslash-and-n, repeated write, or claimed success cannot satisfy the independent +byte oracle. The flow uses native file tools in the registered AGENT_HOME, +requires a stopped-run save receipt and public managed-file readback, then restarts the server and verifies exact bytes from a fresh task. An attempted write to an unassigned isolated sibling path must fail without creating a file. `restrictive-denial` requires a correlated failed native write and absent @@ -334,20 +374,40 @@ no database writes, private hooks, or fabricated provider results are allowed. `native-pending-controller-restart` restarts the public controller while one Pi input callback remains unanswered. It requires the same durable interaction, request, live run, native session, turn and producer before and after restart. +For local execution, the public run's exact PID, process group and start identity +must identify an already-observed durable runner under this controller. The test +preserves only that runner tree during restart, checks the same live identity +afterward, and keeps the old process owner for complete final cleanup alongside +the replacement controller. Other controller children are retired normally. +Remote execution does not infer local process authority from remote PIDs. Only then does the browser submit previously undisclosed text. One durable resolution, one original successful turn and independently read exact workspace JSON prove delivery. A replacement run, replay, cancellation, expiry, rewritten request or merely reloaded browser cannot pass. Full states and PRP identities stay in private snapshots under the existing publication allowlist. +The local runner's unique `turn.submitted` receipt may precede assignment of the +provider turn ID. The oracle accepts that missing ID only before the single +matching `turn.started` and request creation, with the same session and producer +and increasing durable/source sequence numbers. Later missing or changed turn +identities still fail. ### Pi file editing and registered artifacts Pi's `extended-harnesses/file-edit-validate` seeds exact bytes before startup and -requires one native edit lifecycle followed by a successful native bash execution -with the exact nonce-bound byte-validation command as its projected title and a -validation marker. A marker-only echo cannot pass. Independent final bytes must -match the fixture. The -real `register_deliverable` receipt, attachment metadata, publication activity, +requires one native edit lifecycle followed by exactly one successful native +bash execution with the exact nonce-bound byte-validation command as its +projected title and a validation marker. A marker-only echo cannot pass. Final bytes must +match the fixture. The Pi task prompt explicitly forbids additional shell calls, +including metadata commands and repair attempts. It supplies the expected post-edit +byte size and hash for registration. Extended definition 5 places the exact command in a +fenced Bash block so the production Markdown editor preserves its operators and +literal escapes. Definition 4's escaped-paragraph attempt keeps its failed grade. +Extra Bash calls fail the unchanged oracle even when the +edited file and downloadable artifact are correct. Extended definition 4 makes +Pi's artifact title equal the exact filename required by the shared registered +artifact check. Definition 3 attempts retain their original definitions and +grades; its Pi prompt requested a different title. The real +`register_deliverable` receipt, attachment metadata, publication activity, visible task attachment and authenticated public download must all agree on the file's bytes, hash, company, issue, agent and originating run. A file on disk or a model completion claim cannot substitute for publication. Daytona additionally @@ -383,6 +443,40 @@ ancestry and fresh PID/start-time checks. Pi overwrites Linux argv via `process.title`, so the private receipt explicitly uses pinned-parent entrypoint attribution and never claims original child argv. A pidfd targets only that child; worker death, broad process-name matching and controller Stop cannot substitute. +The production bootstrap may name its held executable as `/proc/self/fd/3` +or `/proc/self/fd/7` in the wrapper argv. That form is admitted only when the +wrapper's corresponding descriptor and executable both have the exact sealed +snapshot Node inode. The guard and wrapper entrypoint paths remain exact. +Missing, foreign or other descriptor numbers fail before signalling. +Production may also stage the runner executable as a link to the image's +verified installation. The fault helper reads the resolved regular file, checks +that the named link remained unchanged, and still requires its pinned hash and +exact `/proc//exe` inode. Link replacement, missing targets and a +different executable fail admission. Linux calibration covers this installation +form as well as a copied runner. + +The agent-memory fixture requires the nonce's UTF-8 bytes followed by exactly +one line-feed byte (`0x0A`). Its prompt states that byte contract in plain text +and provides the exact content as fenced JSON. Fenced task prompts use the rich +editor's Markdown paste path; filling the editor directly produces escaped +paragraph text instead of a code block. The issue API preserves literal escapes +in real multiline bodies and only recovers self-escaped line breaks in legacy +single-line bodies. Code fences and JSON escapes must survive both boundaries. +The prompt explicitly states that native write never adds a newline and that +complete native read preserves one when present. A credential-free probe of the +installed Pi 1.0.0 tools checks both a 32-byte value and the 33-byte value with a +final line feed; both write and read retain the exact supplied bytes. +The prompt orders one memory write, one complete native read, a separate expected +cross-root write denial, and then completion. Native paths use the exact current +absolute agent directory; shell-variable expansion is not assumed. An incorrect +memory result must be reported without claiming success. Native readback and the +managed-file API must retain the exact bytes across a new task and controller +restart. The byte graders remain unchanged. + +Controller cleanup can admit a replacement group member only when its ancestry +belongs to a separately revalidated, continuously owned process. A recycled +numeric group, a changed PID/start identity, or any unowned live member still +fails cleanup before signaling. This rule is recorded in `pi-native` version 12. The runtime itself must emit `runtime_request.expired` for the original callback with `provider_process_lost` and `replayAllowed:false`, followed by native turn @@ -403,13 +497,18 @@ macOS runs metadata negatives and explicitly skips this Linux-only calibration. That calibration and the earlier fake-Pi wrapper/bridge tests do not count as the real paid Product lifecycle proof. This new candidate cell remains unqualified. +The pending-question controller-restart browser matcher accepts only the retained +issue's UUID or public identifier and the exact retained interaction ID. The UI +normally posts with the public identifier. Durable request, run, turn, session, +producer and single-delivery assertions remain required after submission. + ## Pi active controls The explicit-only `pi-controls` suite adds `pending-permission-stop` and `same-turn-steering` on local and Daytona, each with one provider run, a 120-second active-turn timeout and a 300-second attempt budget. These four control cases retain their behavior; the current matrix totals 26 Pi cells. -Pi 1/profile 12 and coverage revisions intentionally change the affected suite +Pi 1/profile 13 and coverage revisions intentionally change the affected suite fingerprints, so older qualification receipts cannot be reused. Catalog presence and deterministic calibration do not constitute paid qualification. @@ -422,6 +521,26 @@ identity mapping. Pi does not emit Cursor/Copilot diagnostic notices; those notices are never synthesized. Earlier native reads can provide orientation; other native operations cannot substitute for the observed write. +On Daytona, the operator first publishes the setup instruction file after the +owned observer is armed. If Pi delegates that native read, the fixture approves +only its exact request through the public API with `accept` (allow once). The +read must name the published random setup file, complete successfully in the +same native run/turn/session/source, and leave the file hash unchanged. The +fixture retains the request, resolution, completed read and both observer +snapshots. Only the two hash-bound setup permission records are excluded from +the write-permission count. All native read rows remain in the oracle. Another +permission, edit, shell command, foreign path or incomplete read cannot receive +this exemption. The tested write remains unanswered until Stop or browser Deny. +The production permission policy and all write/retirement assertions stay in +place. `pi-controls` version 7 and `pi-native` version 5 record this correction; +older attempt fingerprints and grades remain historical evidence. + +Native tool arguments can arrive after the start event. An earlier null target +is allowed only until the same execution first supplies the exact expected +path. Missing targets, conflicting paths, another execution's path, or a later +loss of the proven path fail. The original start and permission rows remain +bound by retained hashes through control dispatch and settlement. + Stop awaits the pending evidence write and rereads that boundary before sending one caller UUID to the public cancel API. It requires the original request's normalized cancellation closure, a cancelled terminal, and the same-scope @@ -432,9 +551,11 @@ Only after cancellation does it attempt a stale **decline**, which must return cancelled run and no automatic continuation. Steering submits a random marker only in a browser comment after the permission -is pending, then clicks that comment's production Steer button. It records the -exact public POST's queue/revision/run binding and requires the saved run -acknowledgment plus the Product facade's same-turn acknowledgment item. The raw +is pending, binds the queued comment to the exact body submitted by the +production Markdown editor, then clicks that comment's production Steer button. It records the +exact public POST's queue/revision/run binding. A rejected public POST ends the +journey before any denial. Success requires the saved run acknowledgment plus +the Product facade's same-turn acknowledgment item. The raw Rust `acpx-control-*` transport echo is suppressed by the facade; `CodexHarnessSession.steer` emits the durable correlated item after the command acknowledges. A deterministic calibration invokes that actual producer. The @@ -730,3 +851,5 @@ as completed and end the native turn; Paperclip must retain a failed run with missing semantic finalization and an unfinished task. That is a denial outcome, not task success or operator cancellation. Stop during an unresolved permission remains a separate `native-active-stop/pending-permission-stop` gate. + +Pi controls definition 9 and native definition 16 create an explicit title through the production search creation action and bind the task ID from the public creation response. The scoped task and assignee must match before any native control. Automatic naming is outside these strict native-operation fixtures; all existing permission, byte, process, run-count and cleanup assertions remain required. Preserve the failed title-lookup attempt as its original failure. diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index e1d3e179e2..30b7707929 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -1581,6 +1581,15 @@ death is excluded because this exact ownership mechanism requires Linux pidfd. The Python helper calibration uses a synthetic transport and a real title-changing Node child; it proves fault ownership only, never paid Pi lifecycle behavior. Run it on native Linux with pinned Node on PATH: `python3 tests/runner-e2e/pi-provider-fault.test.py`. +The calibration covers direct launch and the production bootstrap's held FD 3 +and FD 7 launches. Descriptor launch requires the held descriptor and wrapper +executable to match the sealed snapshot Node inode; an argv alias alone grants +no authority. Native suite definitions 6 retain the original live failures and +require new provider-death qualification after this fixture correction. +The fault helper validates the profile pin against canonical normalized closure +entries, matching production admission. JSON formatting is not part of that pin. +The same free suite checks canonical hash admission, malformed metadata and +changed entries before any signal is sent. This adds one cell to the pending Pi Product matrix (26: 13 local, 13 Daytona); no qualification or live success is implied by discovery or oracle tests. @@ -1594,8 +1603,17 @@ the durable browser form bridge. The existing five extended-harness journeys continue to prove semantic Paperclip questions and planning separately. Personal file persistence uses two fresh task runs and independent byte checks; restrictive denial uses one run and requires both a failed tool receipt and no file effect. +The first managed-file API response is saved before its byte assertion, including +when the final LF is missing. An incomplete remote terminal receipt retains only +validated completion flags, watcher counts, process counts and target hashes. +Raw RPC fields and file bodies are omitted. These diagnostic records do not +change a failed byte or retirement grade and do not justify an unchanged retry. `human-permission-denial` additionally requires the exact browser Decline response, delivered native denial and independent file/process observation through retirement. +The restrictive Daytona fixtures approve only the exact operator-published +setup-file read after observer arming. They retain its public resolution and +completed native read before testing the separate write permission. This setup +approval does not change production policy or answer the tested write. See [the fixture contract](FIXTURES.md#pi-native-boundaries) for the exact oracles and the limits of reconnect evidence. @@ -1685,7 +1703,10 @@ The observer watches registered targets (including transient create/delete), use workspace changes and the exact runner process plus descendants. The single controller-owned `.paperclip-runtime/paperclip-runner` subtree is excluded from user-file inventory and mutation counts: it holds the binary, provider pack, -context and active runtime state. Its location and identity remain checked, and +context, active runtime state, and sandbox GitHub launchers, upload locks and +per-command configuration. Launcher restaging after controller recovery uses +that same runtime path. SSH and local launcher locations remain separate. +Its location and identity remain checked, and test targets cannot use it. This exclusion is recorded in evidence; it does not claim that runtime-internal writes are covered by the user-file oracle. @@ -1697,6 +1718,25 @@ as unverified metadata; the remote process is bound through the exact sandbox, run ID, lifecycle, process group and executable digest. Same-UID observer isolation is not an adversarial operating-system sandbox test. +Directory notifications remain counted. A notification for an existing directory +preserves completeness only when its device/inode still match an already +registered recursive watch. Newly created directories, replacements, symlinks, +and unknown notifications remain incomplete. Pi native definition v13 also gives +memory content a single JSON representation, including the required final LF; +the exact managed bytes and fresh-task readback assertions remain unchanged. + +Pi native definition v14 seeds `memory/.pi-e2e-parent.txt` through the public +managed-file API before task admission. This creates the watched parent while +leaving `memory/pi-native.txt` absent; the native write still has to supply all +33 bytes and both turns must preserve the setup file. New or replaced watched +directories still fail the oracle. Closed incompleteness reasons identify watch +gaps or process ambiguity without retaining paths or raw errors. An incomplete +receipt remains failed. A validated terminal receipt with a captured, retired +process tree closes its observer without another RPC to a publicly deleted lease +only when evidence is complete or its failures are known filesystem-watch gaps. +Unknown causes, reused identities and live attached processes still need cleanup +proof. + `--suite rich-acp-warm-continuity` adds six explicit cells: all three providers on local and Daytona. Three browser-driven turns must preserve native session, provider session, runner instance, PID/start identity and project workspace. @@ -1918,6 +1958,65 @@ are retained privately alongside the grading checkpoints for failure diagnosis. Claude receives a fresh provider home and config directory inside the disposable workspace so a user's installed skill cannot shadow the managed skill under test. +### Published-install Pi lane + +The 26 explicit Pi cells can run against an independently installed public CLI +and server instead of the source CLI. Supply all four reviewed pins: +`PAPERCLIP_RUNNER_E2E_INSTALLED_CLI` (canonical `paperclipai/dist/index.js`), +`PAPERCLIP_RUNNER_E2E_INSTALLED_CLI_SHA256` (bare SHA-256), +`PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_ROOT` (canonical resolved public server +package root), and `PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_SHA256` (its +`dist/index.js` SHA-256). The CLI must resolve that exact server dependency, with +matching public package versions. Build/install provenance and the complete +installed dependency/assets inventory remain separate required evidence; the +entrypoint checks alone do not prove that closure. + +Run the explicit `paperclipai runtime setup pi` for that installation first. +This lane rejects candidate flags, local/remote daemon overrides, provider asset +or pack overrides, and Node injection. It launches the installed JavaScript CLI +without a TypeScript loader, rechecks its pins on controller restart, and records +`installed-cli-admission.json` in private attempt evidence. It supports only the +explicit Pi extended, native, controls and warm suites, which require none of the +source-only response barriers. Cursor/Copilot qualification gates stay intact. +Pi's historical `qualificationCandidate` fixture selector remains a roster key; +it no longer grants an opt-in when the source Pi declaration admits normal use. + +Installed Pi Daytona cells also require the published plugin fixture inputs +`PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN` (canonical package root), +`PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_AUTHORITY` (canonical JSON file), +and `PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_AUTHORITY_SHA256`. +The authority schema `paperclip.e2e.installed-daytona-plugin/v1` pins a canonical +`graphRoot` and four public packages (`plugin`, `sdk`, `shared`, `daytona`). +Each has `root`, `packageSha256`, `entry`, and `entrySha256`; `plugin` also has +`manifestSha256` and `workerSha256`. Roots must be the named packages beneath +that graph's `node_modules`. The fixture verifies compiled public exports, +exact dependency versions, resolved package identities, and file pins before +launch and again immediately before the ordinary `/api/plugins/install` request. +It records `installed-daytona-plugin-admission.json` privately. Missing pins in +installed Daytona mode fail; they never select the source plugin as a fallback. +Local cells need no Daytona plugin. Source-development lanes retain their +existing fixture path. These entrypoint checks do not replace the separately +required tar provenance and complete installed dependency inventory audit. +The fixture inputs do not reach the production server environment. + +Before a paid installed local cell, use the same launcher and WebServer chain +without provider work: + +```sh +node --import ./cli/node_modules/tsx/dist/loader.mjs \ + tests/runner-e2e/launch.ts --installed-startup-only \ + --id extended-harnesses.runner-acpx-pi.local.hello-complete \ + --max-automatic-retries 0 +``` + +This mode requires the reviewed installed CLI pins, rejects provider credential +inputs, skips local credential-file loading, and runs only health, browser UI, +and zero-company checks. It retains separate private startup evidence and cannot +produce a passing provider case. Existing process, IPC, and scratch cleanup stay +in force. The installed lane invokes Playwright's public JavaScript bin directly +with the current Node; pnpm's generated bin shim would inject `NODE_PATH` into +the WebServer. Arbitrary ambient `NODE_OPTIONS` and `NODE_PATH` remain rejected. + ## Production hiring templates diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md index 2bec73c778..a5c17bb800 100644 --- a/tests/runner-e2e/SECURITY.md +++ b/tests/runner-e2e/SECURITY.md @@ -317,4 +317,28 @@ server environment. GitHub PAT shapes are included in retained-evidence scans. Candidates have no automatic infrastructure retries; spending must be reconciled before a deliberate repeat. +Copilot protection fixtures use production browser/public API permission and +cancellation paths. Their local one-shot socket accepts a nonce/PID only and owns +one fixed bounded child; it cannot select commands, arguments, or paths. The exact +native client command is supplied by the fixture, while production notices retain +only its SHA-256. The fixture keeps private process identities local, closes its +owned socket/child in cleanup, and never signals API-reported PIDs. Directory +watch loss makes the denial oracle incomplete; it must not become a no-effect pass. + +Pi Daytona controls bind process identity to the independent Linux observer's +PID, start ticks and boot ID within the exact admitted run/lease/sandbox. The +controller's `processStartedAt` annotations may change as launch metadata settles +and do not identify a remote process birth. Every remote snapshot and retirement +seal must retain the original birth identity and complete no-effect journal. +Local controls retain their separate public process-authority comparison. + +Pi provider-death admission accepts the native bootstrap's `/proc/self/fd/3` +or `/proc/self/fd/7` wrapper launch only after binding that held descriptor and +the wrapper executable to the sealed snapshot Node inode. Exact guard and +entrypoint paths, closure hashes, Pi title, run/lease ancestry and fresh process +birth checks remain required. Other descriptor aliases, missing descriptors and +foreign inodes fail before any signal. Native Linux calibration exercises both +descriptor launches with synthetic owned processes and no provider credentials; +it does not qualify paid Pi behavior or change previous failed grades. + The private resource-admission marker and raw cleanup results control recovery-state retention independently of evidence packaging. A worker crash after admission keeps the owner-only recovery database; a confirmed pre-allocation bootstrap failure does not. Neither the marker nor the database enters published evidence. diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index 6b386d6e18..d9e8ab036d 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -146,10 +146,10 @@ describe("runner E2E catalog", () => { expect(localIntegrityTasks).toHaveLength(2); expect(openRouterBreadthTasks).toHaveLength(3); expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([ - 63, 12, 6, 8, 2, 2, 2, 30, 3, 16, 16, 2, 6, 8, 46, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1, 116, + 4, 8, 10, 4, 4, 6, 63, 12, 6, 2, 30, 3, 16, 16, 2, 6, 8, 46, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1, 116, ]); - expect(validateRunnerCatalog()).toHaveLength(698); - expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(698); + expect(validateRunnerCatalog()).toHaveLength(722); + expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(722); expect( runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"), ).toHaveLength(48); @@ -460,7 +460,7 @@ describe("runner E2E catalog", () => { expect(contextIntegrityProfiles.map((profile) => profile.id)).toEqual( expect.arrayContaining(pendingContextIntegrityProfiles.map((profile) => profile.id)), ); - expect(UNQUALIFIED_PROFILE_GAPS.pi).toMatch(/no qualified model source/); + expect(UNQUALIFIED_PROFILE_GAPS).not.toHaveProperty("pi"); }); it("blocks pending profiles before provider admission", () => { diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index 5c87b27b16..d2a1b88871 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -1,3 +1,9 @@ +import { nativeActiveStopTasks } from "./native-active-stop-tasks.js"; +import { piControlTasks } from "./pi-controls-cases.js"; +import { cursorNativeTasks } from "./cursor-native-cases.js"; +import { copilotProtectionTasks } from "./copilot-protection-tasks.js"; +import { piNativeTasks } from "./pi-native-cases.js"; +import { piFilePrompt } from "./pi-file-evidence.js"; import { planTaskCases, planTaskProfile, planDefinitionDigest } from "./plan-task-cases.js"; import { nativeCompletionTasks, nativeCompletionDefinitionDigest } from "./native-completion-cases.js"; import { NATIVE_INSTRUCTION_SUITE, NATIVE_INSTRUCTION_BASE_SHA, nativeInstructionDefinitionDigest } from "./native-instruction-consolidation.js"; @@ -5,8 +11,6 @@ import { nativeCompletionProfile, NATIVE_COMPLETION_BUDGET_CENTS } from "./nativ import { chatConfirmationTasks } from "./chat-cases.js"; import { buildConnectionSuite } from "./connection-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"; @@ -168,6 +172,8 @@ function legacyProfile(input: { }; } +const PI_QUALIFICATION_THINKING_LEVEL = "low" as const; + function nativeProfile(input: { id: string; label: string; @@ -219,6 +225,7 @@ function nativeProfile(input: { lifecycleMode: "per_turn", idleTimeoutMs: 300_000, ...permissionConfig, + ...(input.acpxAgent === "pi" ? { piThinkingLevel: PI_QUALIFICATION_THINKING_LEVEL } : {}), env: { ...(credentialRef ? { [input.credential]: credentialRef } : {}), // Codex's supported automation credential is CODEX_API_KEY. Keep @@ -352,7 +359,7 @@ export const extendedHarnessProfiles: readonly RunnerProfileFixture[] = [ nativeProfile({ id: "runner-acpx-pi", label: "Runner Pi (candidate)", provider: "acpx", acpxAgent: "pi", qualificationCandidate: "pi", credential: "OPENROUTER_API_KEY", model: ACPX_QUALIFICATION_MODELS.pi, - modelQualification: { source: "candidate_runner_profile", qualificationId: "pi:0.0.33:0.84.2:openrouter" }, + modelQualification: { source: "candidate_runner_profile", qualificationId: "pi:0.0.33:1.0.0:openrouter" }, }), ]; @@ -1096,6 +1103,70 @@ export const extendedHarnessFileTask: RunnerTaskFixture = { }; export const runnerSuites: readonly RunnerSuiteFixture[] = [ + { + id: "pi-controls", label: "Pi active controls", manualOnly: true, + description: "Pending native-write Stop and browser-originated same-turn steering, with exact control receipts and independent retirement/no-effect evidence.", + groups: ["native"], profiles: extendedHarnessProfiles.filter(profile => profile.qualificationCandidate === "pi"), + environments: runnerEnvironments, tasks: piControlTasks, expectedMatrixSize: 4, + definitionMetadata: { version: 10, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", qualification: "pending", scheduling: "explicit-only", profileVersion: QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, + nativeArguments: "streamed-until-exact-target", + remoteProcessIdentity: "observer-pid-startTicks-bootId", + remoteBootstrapAdmission: "owned-active-lease-with-native-runtime-readiness-rpc-v1", remoteBootstrapApproval: "exact-published-native-read-public-accept-once-v1", + controlPlaneSettlement: "required-scoped-result-and-terminal-after-runner", + steeringComment: "exact-browser-submitted-markdown", + steeringDispatch: "require-public-api-acceptance", + pending: "paperclip.e2e.pi-control-pending.v1", stop: "paperclip.e2e.pi-stop-settlement.v1", steering: "paperclip.e2e.pi-steering-settlement.v1", + permissionPolicy: "approve-reads", lifecycle: "per_turn", normalCompletionProvesStop: false, nativeFollowUp: "not-covered", providerDeath: "not-covered", + remoteObservationCoverage: "continuous-through-owned-process-retirement", filesystemAfterRemoteRetirementObserved: false }, + }, + { + 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: "pi-native", label: "Pi native boundaries", manualOnly: true, + description: "Pi native forms, registered agent files and human permission denial on local and Daytona execution; automatic deny-all remains local-only.", + groups: ["native"], profiles: extendedHarnessProfiles.filter(profile => profile.qualificationCandidate === "pi"), + environments: runnerEnvironments, tasks: piNativeTasks, expectedMatrixSize: 10, + excludedExecutionIds: ["pi-native.runner-acpx-pi.daytona.restrictive-denial", "pi-native.runner-acpx-pi.local.native-pending-provider-death"], + definitionMetadata: { version: 23, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", agentMemoryParent: "public-managed-file-seed-before-admission", incompleteTerminalCleanup: "retirement-retained-with-failed-watch", qualification: "pending", scheduling: "explicit-only", profileVersion: QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, agentMemoryContent: "utf8-nonce-plus-final-lf", agentMemoryPrompt: "single-json-write-and-content-bound-native-read-both-runs", agentMemoryReadAuthority: "local-withheld-or-exact-remote-agent-run-file", taskPromptTransport: "fenced-markdown-paste-and-multiline-literal-escapes", nativeFinish: "current-contract-objective-evidence-refs", providerDeath: "daytona-exact-pi-child-pidfd-production-expiry", providerFaultExecutable: "stable-preinstalled-runner-link-and-snapshot-node-inode-with-held-bootstrap-fd-3-or-7", remoteBootstrapAdmission: "owned-active-lease-with-native-runtime-readiness-rpc-v1", remoteBootstrapApproval: "exact-published-native-read-public-accept-once-v1", remoteDenyAll: "unsupported-native-bootstrap-read-is-denied", remoteEvidence: "owned-lease-sealed-observer", pendingControllerRestart: "same-live-native-request-trusted-ancestry-cleanup" }, + }, + { + id: "native-active-stop", label: "Stop an unanswered native permission", manualOnly: true, + description: "Stop while one exact Cursor or Copilot 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 => ["cursor", "copilot"].includes(profile.qualificationCandidate ?? "")), + environments: runnerEnvironments, tasks: nativeActiveStopTasks, expectedMatrixSize: 4, + 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: "copilot-protection", label: "Copilot native protection", manualOnly: true, + description: "Exact native denial with independently correlated provider settlement and acknowledged run Stop, plus attached command settlement with independent process evidence.", + groups: ["native"], profiles: extendedHarnessProfiles.filter(profile => profile.qualificationCandidate === "copilot"), + environments: runnerEnvironments, tasks: copilotProtectionTasks, expectedMatrixSize: 4, + definitionMetadata: { version: 9, outputProhibition: "separate-publication-and-attachment", semanticCompletionEvidence: "paperclip.e2e.copilot-semantic-completion.v2", qualification: "pending", naturalSettlementObservationMs: 2000, scheduling: "explicit-only", evidence: "copilot_tool_evidence_v1", profileVersion: QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion, denialTerminal: "correlated-provider-settlement-and-audited-run-stop", denialSettlementEvidence: "paperclip.e2e.copilot-denial-settlement.v3", activeTurnCancellation: "not-implied-by-completed-provider-turn", settlement: "attached-finite-command-only", settlementMarker: "private-diagnostic-not-deliverable", remoteEvidence: "owned-lease-sealed-observer" }, + }, + { + 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, Copilot and Pi.", + groups: ["native", "warm"], + profiles: extendedHarnessProfiles.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], + // ACP collects changed agent files only after provider retirement. Keep this + // process-continuity fixture on workspace writes; Codex retains its separate + // managed-home checkpoint fixture, and Pi covers saved files in a fresh run. + tasks: [{ ...daytonaWarmContinuityTask, + buildPrompt: nonce => warmTurnInstructions(1, nonce), + buildFollowupMessages: nonce => [warmTurnInstructions(2, nonce), warmTurnInstructions(3, nonce)], + turnTimeoutMs: 120_000, attemptTimeoutMs: { local: 420_000, daytona: 420_000 } }], + expectedMatrixSize: 6, + definitionMetadata: { version: 2, qualification: "pending", scheduling: "explicit-only", identity: "native-session-runner-provider-session-process-start", agentFiles: "unchanged-home-process-continuity" }, + }, { id: "public-mcp", label: "Paperclip through an assistant", manualOnly: true, description: "Paid assistant tool use plus actual team execution, browser OAuth consent, durable outcomes and authorization boundaries.", @@ -1131,22 +1202,7 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ groups: ["legacy"], profiles: runnerProfiles.filter(p => ["legacy-codex", "legacy-claude"].includes(p.id)).map(blockerProfile), 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: "native-provider-loss", label: "Lose a runtime with an unanswered native permission", manualOnly: true, description: "Lose the owned Cursor runtime while a native mutation remains unanswered; require a visible failed run, closed unanswerable input, stale-answer refusal and independent retirement with no effects or replay.", groups: ["native"], profiles: extendedHarnessProfiles.filter(profile => profile.qualificationCandidate === "cursor"), @@ -1161,27 +1217,13 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ buildMatchers: () => [], }], expectedMatrixSize: 2, definitionMetadata: { version: 1, qualification: "pending", scheduling: "explicit-only", fault: "observed-per-turn-run-root-loss", remoteFaultAuthority: "pidfd-start-ticks-boot-id", replayAllowed: false }, - }, - { - 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.", groups: ["native"], profiles: extendedHarnessProfiles, environments: runnerEnvironments, tasks: [...openRouterBreadthTasks, localIntegrityTasks[1]!, extendedHarnessFileTask], expectedMatrixSize: 30, - definitionMetadata: { version: 2, qualification: "pending", scheduling: "explicit-only", admission: "host-exact-candidate-and-model", authenticatedDiscoveryDate: "2026-09-28", piFileEvidence: "seed-edit-execute-public-download-v1" }, + definitionMetadata: { version: 5, qualification: "pending", scheduling: "explicit-only", admission: "host-exact-candidate-and-model", authenticatedDiscoveryDate: "2026-09-28", piFileEvidence: "seed-edit-single-execute-public-download-v2", piFileArtifactTitle: "exact-filename", piFileCommandTransport: "fenced-bash-markdown-paste" }, }, { id: "instruction-persistence", label: "Instruction Persistence", @@ -1563,6 +1605,7 @@ export function suiteDefinitionHash(suite: RunnerSuiteFixture) { id: profile.id, model: profile.model, qualification: profile.modelQualification, + ...(profile.qualificationCandidate === "pi" ? { piThinkingLevel: PI_QUALIFICATION_THINKING_LEVEL } : {}), })), environments: suite.environments.map((environment) => ({ id: environment.id, @@ -1662,6 +1705,8 @@ export function validateRunnerCatalog(): MatrixExecution[] { const connectionSuite = runnerSuites.find(suite => suite.id === "provider-connections")!; const allProfiles = [...connectionSuite.profiles, ...extendedHarnessProfiles, ...runnerProfiles, ...legacyAcpxProfiles, ...pendingContextIntegrityProfiles, ...openRouterBreadthProfiles, ...everydayProfiles.filter(p => !runnerProfiles.some(existing => existing.id === p.id))]; const allTasks = [ + ...piNativeTasks, + ...copilotProtectionTasks, ...connectionSuite.tasks, extendedHarnessFileTask, ...contextIntegrityTasks, diff --git a/tests/runner-e2e/cleanup-verification.test.ts b/tests/runner-e2e/cleanup-verification.test.ts index 54d9a127c0..fdca9d33df 100644 --- a/tests/runner-e2e/cleanup-verification.test.ts +++ b/tests/runner-e2e/cleanup-verification.test.ts @@ -1,39 +1,5 @@ import { expect, it } from "vitest"; -import { mayAllocateRemoteResources, mustPreserveRecoveryState, runCleanupWithObservers, shouldKeepFailedDiagnostics, verifyCleanupAssertions } from "./cleanup-verification.js"; - -it("keeps late failures and incomplete publication for explicit diagnosis", () => { - expect(shouldKeepFailedDiagnostics({ enabled: true, expectedResults: 1, results: [{ status: "failed" }] })).toBe(true); - expect(shouldKeepFailedDiagnostics({ enabled: true, expectedResults: 2, results: [{ status: "passed" }] })).toBe(true); - expect(shouldKeepFailedDiagnostics({ enabled: true, expectedResults: 1, results: [] })).toBe(true); - expect(shouldKeepFailedDiagnostics({ enabled: true, expectedResults: 1, results: [{ status: "passed" }] })).toBe(false); - expect(shouldKeepFailedDiagnostics({ enabled: false, expectedResults: 1, results: [] })).toBe(false); -}); - -it("marks only environments that can allocate remote resources", () => { - expect(mayAllocateRemoteResources("daytona")).toBe(true); - expect(mayAllocateRemoteResources("local")).toBe(false); -}); - -it("retains uncertain remote allocation state even after every local process exits", () => { - expect(mustPreserveRecoveryState({ processCleanupFailed: false, results: [{ cleanup: "failed" }] })).toBe(true); - expect(mustPreserveRecoveryState({ processCleanupFailed: false, results: [{ cleanup: "not_started" }] })).toBe(false); - expect(mustPreserveRecoveryState({ processCleanupFailed: true, results: [{ cleanup: "passed" }] })).toBe(true); - expect(mustPreserveRecoveryState({ processCleanupFailed: false, results: [{ cleanup: "passed" }] })).toBe(false); -}); - -it("retains raw cleanup failures even when no evidence was published", () => { - expect(mustPreserveRecoveryState({ processCleanupFailed: false, resourceAdmissionStarted: true, results: [{ cleanup: "failed" }] })).toBe(true); -}); - -it("distinguishes pre-admission bootstrap failures from uncertain worker failures", () => { - const synthetic = { cleanup: "not_started", synthetic: true }; - expect(mustPreserveRecoveryState({ processCleanupFailed: false, results: [synthetic] })).toBe(false); - expect(mustPreserveRecoveryState({ processCleanupFailed: false, resourceAdmissionStarted: true, results: [synthetic] })).toBe(true); - expect(mustPreserveRecoveryState({ processCleanupFailed: false, resourceAdmissionStarted: true, results: [] })).toBe(true); - expect(mustPreserveRecoveryState({ processCleanupFailed: false, results: [] })).toBe(false); - expect(mustPreserveRecoveryState({ processCleanupFailed: false, resourceAdmissionStarted: true, results: [{ cleanup: "not_started" }] })).toBe(false); - expect(mustPreserveRecoveryState({ processCleanupFailed: false, resourceAdmissionStarted: true, results: [{ cleanup: "passed" }] })).toBe(false); -}); +import { runCleanupWithObservers, verifyCleanupAssertions } from "./cleanup-verification.js"; it("retains a failed cleanup proof and still closes every later observer", async () => { let closed = 0; diff --git a/tests/runner-e2e/copilot-protection-flow.test.ts b/tests/runner-e2e/copilot-protection-flow.test.ts index 1db9b4c625..44cd1ee82b 100644 --- a/tests/runner-e2e/copilot-protection-flow.test.ts +++ b/tests/runner-e2e/copilot-protection-flow.test.ts @@ -26,7 +26,7 @@ function projected(name: string, target = "copilot-denied-nonce.txt") { describe("Copilot Product protection integration", () => { it("registers two explicit cases on both environments with honest terminal expectations", () => { const cells = runnerMatrix.filter(x => x.suite.id === "copilot-protection"); - expect(cells[0]!.suite.definitionMetadata).toMatchObject({ version: 8, naturalSettlementObservationMs: 2000, denialTerminal: "correlated-provider-settlement-and-audited-run-stop", + expect(cells[0]!.suite.definitionMetadata).toMatchObject({ version: 9, naturalSettlementObservationMs: 2000, denialTerminal: "correlated-provider-settlement-and-audited-run-stop", denialSettlementEvidence: "paperclip.e2e.copilot-denial-settlement.v3", activeTurnCancellation: "not-implied-by-completed-provider-turn" }); expect(suiteDefinitionHash(cells[0]!.suite)).not.toBe(suiteDefinitionHash({ ...cells[0]!.suite, definitionMetadata: { version: 2 } })); expect(cells).toHaveLength(4); expect(new Set(cells.map(c => c.environment.id))).toEqual(new Set(["local", "daytona"])); diff --git a/tests/runner-e2e/copilot-protection-tasks.ts b/tests/runner-e2e/copilot-protection-tasks.ts index ff4a05824d..842beb8d6e 100644 --- a/tests/runner-e2e/copilot-protection-tasks.ts +++ b/tests/runner-e2e/copilot-protection-tasks.ts @@ -11,7 +11,7 @@ export const copilotProtectionTasks: readonly RunnerTaskFixture[] = copilotProte "Run the supplied exact command once using native bash with mode async and detach false.", "After starting it, attempt to finish immediately without calling read_bash or another waiting tool. The attached-process runtime must settle the command before ending the turn.", "Do not modify fixture code, manufacture its result, or start another command.", - "The fixture-owned marker is a private diagnostic sentinel, not a requested file deliverable. Do not publish or attach it.", + "The fixture-owned marker is a private diagnostic sentinel, not a requested file deliverable. Do not publish it. Do not attach it.", `Call paperclip_finish with reportedWorkDisposition done, summary COPILOT-PROTECTION-${c.id}-${nonce}, current completion contract revision, satisfied objective criterion, no remaining work, evidence [], verification []. After acceptance reply with that exact summary.`, ].join("\n"), buildMatchers: () => [], // Flow grades public durable origin and independent OS/filesystem observations. diff --git a/tests/runner-e2e/copilot-semantic-evidence.test.ts b/tests/runner-e2e/copilot-semantic-evidence.test.ts index 8f93315a37..7abf1dc019 100644 --- a/tests/runner-e2e/copilot-semantic-evidence.test.ts +++ b/tests/runner-e2e/copilot-semantic-evidence.test.ts @@ -32,7 +32,7 @@ function setField(r: any, key: string, value: string) { frame(r).payload.details describe("Copilot semantic completion public-event oracle", () => { it("versions the actual Copilot suite oracle without changing denial settlement", () => { const suite = runnerSuites.find(s => s.id === "copilot-protection")!; - expect(suite.definitionMetadata).toMatchObject({ version: 8, semanticCompletionEvidence: "paperclip.e2e.copilot-semantic-completion.v2", denialSettlementEvidence: "paperclip.e2e.copilot-denial-settlement.v3" }); + expect(suite.definitionMetadata).toMatchObject({ version: 9, semanticCompletionEvidence: "paperclip.e2e.copilot-semantic-completion.v2", denialSettlementEvidence: "paperclip.e2e.copilot-denial-settlement.v3" }); expect(suiteDefinitionHash(suite)).not.toBe(suiteDefinitionHash({ ...suite, definitionMetadata: { ...suite.definitionMetadata, version: 7 } })); }); it("joins exact native lifecycle, authoritative callback, proposed content and accepted control-plane result", () => { diff --git a/tests/runner-e2e/cursor-native-cases.ts b/tests/runner-e2e/cursor-native-cases.ts index 9ca4add5d0..f792fd1c8a 100644 --- a/tests/runner-e2e/cursor-native-cases.ts +++ b/tests/runner-e2e/cursor-native-cases.ts @@ -9,10 +9,10 @@ import type { RunnerTaskFixture } from "./types.js"; * 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 }, + { id: "native-question-reconnect", method: "cursor/ask_question", mode: "plan", permissionMode: "approve-all", expectedRunCount: 1 }, + { id: "native-plan-reject-revise-accept", method: "cursor/create_plan", mode: "plan", permissionMode: "approve-all", expectedRunCount: 1 }, + { id: "native-plan-cancel", method: "cursor/create_plan", mode: "plan", permissionMode: "approve-all", expectedRunCount: 1 }, + { id: "native-write-deny-reconnect", method: "session/request_permission", mode: "agent", permissionMode: "approve-reads", expectedRunCount: 1 }, ] as const; export type CursorNativeMethod = typeof cursorNativeCaseDesigns[number]["method"]; diff --git a/tests/runner-e2e/cursor-native-flow.test.ts b/tests/runner-e2e/cursor-native-flow.test.ts index 27c845f18a..6af5af3368 100644 --- a/tests/runner-e2e/cursor-native-flow.test.ts +++ b/tests/runner-e2e/cursor-native-flow.test.ts @@ -10,8 +10,8 @@ it("keeps native mode/permission choices explicit and artifact export pending", 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(cursorNativeCaseDesigns.filter(row => row.method !== "session/request_permission").every(row => row.mode === "plan")).toBe(true); + expect(cursorNativeCaseDesigns.find(row => row.method === "session/request_permission")).toMatchObject({ mode: "agent", permissionMode: "approve-reads" }); expect(cursorNativeTasks.find(task => task.id === "native-write-deny-reconnect")!.expectedTerminalState).toEqual({ issue: "in_progress", run: "failed" }); expect(cursorNativePlanArtifactGate.status).toBe("pending"); expect(cursorNativePlanArtifactGate.nativePath).toContain(""); diff --git a/tests/runner-e2e/cursor-native-flow.ts b/tests/runner-e2e/cursor-native-flow.ts index 6a76ed9145..346e6c3a7d 100644 --- a/tests/runner-e2e/cursor-native-flow.ts +++ b/tests/runner-e2e/cursor-native-flow.ts @@ -113,10 +113,10 @@ export async function runCursorNativeFlow(input: { 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"); + const configured = await api.patch(`/api/agents/${fixtures.agent.id}`, { adapterConfig: { ...agent.adapterConfig, acpxSessionMode: design.mode, acpxPermissionMode: design.permissionMode, ...(remote || design.id === "native-write-deny-reconnect" ? { lifecycleMode: "per_turn", timeoutSec: 120 } : {}) } }); + check("explicit-mode-policy", configured.adapterConfig.acpxSessionMode === design.mode && 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 }); + await input.evidence("cursor-native-contract.json", { caseId: design.id, mode: design.mode, 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 }, diff --git a/tests/runner-e2e/daytona-image-content.ts b/tests/runner-e2e/daytona-image-content.ts index 0bb601acf0..4ccda56aa2 100644 --- a/tests/runner-e2e/daytona-image-content.ts +++ b/tests/runner-e2e/daytona-image-content.ts @@ -40,16 +40,20 @@ export const DAYTONA_IMAGE_INPUT_PATHS = [ "packages/paperclip-runner/scripts/provider-pack-executable-shims.mjs", "packages/paperclip-runner/scripts/build-node-startup-timeout.mjs", "packages/paperclip-runner/scripts/candidate-provider-pack.mjs", + "packages/paperclip-runner/scripts/build-copilot-distribution.mjs", + "packages/paperclip-runner/scripts/materialize-copilot-binary.mjs", "packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs", "packages/paperclip-runner/scripts/cursor-runtime-patch.mjs", + "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", + "packages/paperclip-runner/scripts/materialize-pi-distribution.mjs", + "packages/paperclip-runner/scripts/pi-distribution", "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", diff --git a/tests/runner-e2e/daytona-image.test.ts b/tests/runner-e2e/daytona-image.test.ts index 2fc8c61ffb..4c8af724c7 100644 --- a/tests/runner-e2e/daytona-image.test.ts +++ b/tests/runner-e2e/daytona-image.test.ts @@ -88,6 +88,14 @@ describe("runner E2E Daytona image contract", () => { expect(dockerignore).toContain("**/node_modules"); expect(dockerignore).toContain("packages/paperclip-runner/dist"); expect(dockerignore).toContain("packages/paperclip-runner/runner/target"); + expect(await readFile(path.join(repositoryRoot, ".github/docker-context-checks.Dockerfile"), "utf8")).toContain("node packages/paperclip-runner/scripts/generate-acpx-profiles.mjs --check"); + for (const declaration of [ + "test-fixtures/pi-acp/profile-v19-identity.json", + "test/fixtures/copilot-profile-v16-identity.json", + ]) { + expect(dockerignore).toContain(`!packages/paperclip-runner/${declaration}`); + await expect(readFile(path.join(repositoryRoot, "packages/paperclip-runner", declaration), "utf8")).resolves.toBeTruthy(); + } for (const developmentOnlyInput of [ "packages/paperclip-runner/devtools", "packages/paperclip-runner/docs", @@ -200,6 +208,8 @@ 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/build-copilot-distribution.mjs", + "packages/paperclip-runner/scripts/materialize-copilot-binary.mjs", "packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs", "packages/paperclip-runner/scripts/cursor-runtime-patch.mjs", "packages/paperclip-runner/cursor-distributions.json", @@ -282,7 +292,11 @@ describe("runner E2E Daytona image contract", () => { ), 'pub const VERSION: &str = "one";\n', ); + await mkdir(path.join(root, "packages/paperclip-runner/scripts/pi-distribution"), { recursive: true }); for (const relativePath of [ + "packages/paperclip-runner/scripts/materialize-pi-distribution.mjs", + "packages/paperclip-runner/scripts/pi-distribution/package.json", + "packages/paperclip-runner/scripts/pi-distribution/package-lock.json", ]) await writeFile(path.join(root, relativePath), "version one\n"); const baseline = await computeDaytonaImageContentId(options); const candidate = await computeDaytonaImageContentId({ ...options, candidateProviders: ["pi"] }); diff --git a/tests/runner-e2e/extended-harnesses.test.ts b/tests/runner-e2e/extended-harnesses.test.ts index 231ecf8cac..a6aa2f85d9 100644 --- a/tests/runner-e2e/extended-harnesses.test.ts +++ b/tests/runner-e2e/extended-harnesses.test.ts @@ -1,6 +1,6 @@ import { assertRemoteNativeEvidencePrerequisites } from "./prerequisites.js"; import { describe, expect, it } from "vitest"; -import { runnerMatrix, runnerSuites, extendedHarnessProfiles, extendedHarnessFileTask } from "./catalog.js"; +import { runnerMatrix, runnerSuites, extendedHarnessProfiles, extendedHarnessFileTask, daytonaWarmContinuityTask } from "./catalog.js"; import { buildRunnerE2EProcessEnvironment, buildPaperclipServerEnvironment } from "./harness-env.js"; import { parseRunnerSelectors, selectRunnerExecutions } from "./selectors.js"; import { findSecretLeak, redactText } from "./redaction.js"; @@ -19,7 +19,7 @@ describe("extended ACP harness qualification", () => { }); it("uses exact discovered models, encrypted credential references and current qualification metadata", () => { expect(extendedHarnessProfiles.map(profile => profile.model)).toEqual([ - "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", "gpt-5.6-luna", "openrouter/deepseek/deepseek-v4-flash-0731", + "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", "gpt-5.6-luna", "openrouter/anthropic/claude-sonnet-4.6", ]); for (const profile of extendedHarnessProfiles) { expect(profile.modelQualification.source).toBe(profile.qualificationCandidate === "cursor" ? "qualified_runner_profile" : "candidate_runner_profile"); @@ -48,17 +48,39 @@ describe("extended ACP harness qualification", () => { expect(cell.task.buildMatchers("nonce", cell)).toContainEqual({ kind: "environment", expected: cell.environment.id }); const config = cell.profile.buildAgent({ executionId: "warm", environmentId: "env", environmentFixtureId: cell.environment.id, workspacePath: "/tmp/workspace", secretRefs: { [cell.profile.credential]: { type: "secret_ref", secretId: "11111111-1111-4111-8111-111111111111", version: "latest" } } }).adapterConfig; expect(config).toMatchObject({ lifecycleMode: "warm", idleTimeoutMs: 300_000, timeoutSec: 120 }); - expect(JSON.parse(buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION!)).toEqual([{ agent: cell.profile.qualificationCandidate, model: cell.profile.model }]); + const admission = buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION; + if (cell.profile.qualificationCandidate !== "copilot") expect(admission).toBeUndefined(); + else expect(JSON.parse(admission!)).toEqual([{ agent: cell.profile.qualificationCandidate, model: cell.profile.model }]); expect(() => buildRunnerE2EProcessEnvironment({}, [{ ...cell, suite: { ...cell.suite, manualOnly: false } }])).toThrow("explicit"); } expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(cell => cell.suite.id === "rich-acp-warm-continuity")).toBe(false); }); + it("separates ACP process continuity from managed-home checkpoint writes", () => { + for (const cell of runnerMatrix.filter(cell => cell.suite.id === "rich-acp-warm-continuity")) { + const prompts = [cell.task.buildPrompt("nonce"), ...cell.task.buildFollowupMessages!("nonce")]; + expect(prompts).toHaveLength(3); + for (const [index, prompt] of prompts.entries()) { + expect(prompt).toContain(`warm Daytona continuity turn ${index + 1} of 3`); + expect(prompt).toContain("daytona-warm-nonce.txt"); + expect(prompt).not.toContain("AGENT_HOME"); + expect(prompt).not.toContain("notes/"); + } + expect(cell.task).toMatchObject({ turnTimeoutMs: 120_000, attemptTimeoutMs: { local: 420_000, daytona: 420_000 } }); + } + const managed = [daytonaWarmContinuityTask.buildPrompt("nonce"), ...daytonaWarmContinuityTask.buildFollowupMessages!("nonce")]; + expect(managed.every(prompt => prompt.includes("AGENT_HOME") && prompt.includes("notes/warm-memory.txt"))).toBe(true); + expect(managed[0]).toContain("8388608 bytes"); + expect(managed[1]).toContain("Delete notes/delete-me.txt"); + expect(managed[2]).toContain("Verify notes/delete-me.txt is absent"); + }); it("admits native qualification only for its matching provider and explicit suite", () => { for (const suiteId of ["cursor-native", "pi-native", "copilot-protection"]) { const cells = runnerMatrix.filter(cell => cell.suite.id === suiteId); expect(new Set(cells.map(cell => cell.environment.id))).toEqual(new Set(["local", "daytona"])); for (const cell of cells) { - expect(buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeDefined(); + const admission = buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION; + if (cell.profile.qualificationCandidate !== "copilot") expect(admission).toBeUndefined(); + else expect(admission).toBeDefined(); expect(() => buildRunnerE2EProcessEnvironment({}, [{ ...cell, profile: { ...cell.profile, qualificationCandidate: cell.profile.qualificationCandidate === "pi" ? "cursor" : "pi" } }])).toThrow("explicit"); } } @@ -71,8 +93,9 @@ describe("extended ACP harness qualification", () => { ); for (const cell of cells) { expect(cell.suite.manualOnly).toBe(true); - expect(JSON.parse(buildRunnerE2EProcessEnvironment({ PAPERCLIP_RUNNER_ACPX_QUALIFICATION: "ambient" }, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION!)) - .toEqual([{ agent: cell.profile.qualificationCandidate, model: cell.profile.model }]); + const admission = buildRunnerE2EProcessEnvironment({ PAPERCLIP_RUNNER_ACPX_QUALIFICATION: "ambient" }, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION; + if (cell.profile.qualificationCandidate === "copilot") expect(JSON.parse(admission!)).toEqual([{ agent: "copilot", model: cell.profile.model }]); + else expect(admission).toBeUndefined(); expect(() => buildRunnerE2EProcessEnvironment({}, [{ ...cell, suite: { ...cell.suite, manualOnly: false } }])).toThrow("explicit"); expect(() => buildRunnerE2EProcessEnvironment({}, [{ ...cell, profile: { ...cell.profile, qualificationCandidate: "pi" } }])).toThrow("explicit"); expect(() => buildRunnerE2EProcessEnvironment({}, [{ ...cell, suite: { ...cell.suite, id: "unrelated-manual-suite" } }])).toThrow("explicit"); diff --git a/tests/runner-e2e/fixtures/pi-file-evidence-actual-stream.json b/tests/runner-e2e/fixtures/pi-file-evidence-actual-stream.json new file mode 100644 index 0000000000..752408372c --- /dev/null +++ b/tests/runner-e2e/fixtures/pi-file-evidence-actual-stream.json @@ -0,0 +1,798 @@ +{ + "schema": "paperclip.e2e.sanitized-pi-file-stream.v1", + "description": "44 retained native Rust-projected edit/bash rows; only fixture nonce and execution identities replaced. Timestamps, private identities and unrelated events omitted. This calibrates the oracle, not the original failed attempt result.", + "events": [ + { + "seq": 71, + "sourceSeq": 46, + "eventType": "tool.execution.started", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "edit (pending)", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 72, + "sourceSeq": 47, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "tool call (pending)", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 73, + "sourceSeq": 48, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "tool call (pending)", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 74, + "sourceSeq": 49, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "tool call (pending)", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 75, + "sourceSeq": 50, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "tool call (pending)", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 76, + "sourceSeq": 51, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "tool call (pending)", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 77, + "sourceSeq": 52, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "tool call (pending)", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 78, + "sourceSeq": 53, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "tool call (pending)", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 79, + "sourceSeq": 54, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "tool call (pending)", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 80, + "sourceSeq": 55, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "tool call (pending)", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 81, + "sourceSeq": 56, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "tool call (pending)", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 82, + "sourceSeq": 57, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "tool call (pending)", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 83, + "sourceSeq": 58, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "tool call (pending)", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 84, + "sourceSeq": 59, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "tool call (pending)", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 85, + "sourceSeq": 60, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": "extended-fixture.txt", + "exitCode": null, + "progress": "tool call (pending): extended-fixture.txt", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 86, + "sourceSeq": 61, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": "extended-fixture.txt", + "exitCode": null, + "progress": "tool call (pending): extended-fixture.txt", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 88, + "sourceSeq": 63, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "running", + "target": "extended-fixture.txt", + "exitCode": null, + "progress": "tool call (in_progress): extended-fixture.txt", + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 89, + "sourceSeq": 64, + "eventType": "tool.execution.completed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "edit", + "operation": "edit", + "transport": "builtin", + "status": "completed", + "target": "extended-fixture.txt", + "exitCode": null, + "progress": null, + "output": "null", + "outputTruncated": false, + "executionId": "edit" + } + }, + { + "seq": 92, + "sourceSeq": 66, + "eventType": "tool.execution.started", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 93, + "sourceSeq": 67, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 94, + "sourceSeq": 68, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 95, + "sourceSeq": 69, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 96, + "sourceSeq": 70, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 97, + "sourceSeq": 71, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 98, + "sourceSeq": 72, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 99, + "sourceSeq": 73, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 100, + "sourceSeq": 74, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 101, + "sourceSeq": 75, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 102, + "sourceSeq": 76, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 103, + "sourceSeq": 77, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 104, + "sourceSeq": 78, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 105, + "sourceSeq": 79, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 106, + "sourceSeq": 80, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 107, + "sourceSeq": 81, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 108, + "sourceSeq": 82, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 109, + "sourceSeq": 83, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 110, + "sourceSeq": 84, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 111, + "sourceSeq": 85, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "bash (pending): [terminal] validate", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 112, + "sourceSeq": 86, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "node -e 'if(require(\"node:fs\").readFileSync(\"extended-fixture.txt\",\"utf8\")!==\"verified-fixture\\n\")throw Error(\"mismatch\");console.log(\"PI-VALIDATED-fixture\")' (pending): node -e 'if(require(\"node:fs\").readFileSync(\"extended-fixture.txt\",\"utf8\")!==\"verified-fixture\\n\")throw Error(\"mismatch\");console.log(\"PI-VALIDATED-fixture\")'", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 113, + "sourceSeq": 87, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "node -e 'if(require(\"node:fs\").readFileSync(\"extended-fixture.txt\",\"utf8\")!==\"verified-fixture\\n\")throw Error(\"mismatch\");console.log(\"PI-VALIDATED-fixture\")' (pending): node -e 'if(require(\"node:fs\").readFileSync(\"extended-fixture.txt\",\"utf8\")!==\"verified-fixture\\n\")throw Error(\"mismatch\");console.log(\"PI-VALIDATED-fixture\")'", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 115, + "sourceSeq": 89, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "node -e 'if(require(\"node:fs\").readFileSync(\"extended-fixture.txt\",\"utf8\")!==\"verified-fixture\\n\")throw Error(\"mismatch\");console.log(\"PI-VALIDATED-fixture\")' (in_progress): node -e 'if(require(\"node:fs\").readFileSync(\"extended-fixture.txt\",\"utf8\")!==\"verified-fixture\\n\")throw Error(\"mismatch\");console.log(\"PI-VALIDATED-fixture\")'", + "output": "null", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 116, + "sourceSeq": 90, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "node -e 'if(require(\"node:fs\").readFileSync(\"extended-fixture.txt\",\"utf8\")!==\"verified-fixture\\n\")throw Error(\"mismatch\");console.log(\"PI-VALIDATED-fixture\")' (in_progress): node -e 'if(require(\"node:fs\").readFileSync(\"extended-fixture.txt\",\"utf8\")!==\"verified-fixture\\n\")throw Error(\"mismatch\");console.log(\"PI-VALIDATED-fixture\")'", + "output": "{\"content\":[]}", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 117, + "sourceSeq": 91, + "eventType": "tool.execution.progressed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "running", + "target": null, + "exitCode": null, + "progress": "node -e 'if(require(\"node:fs\").readFileSync(\"extended-fixture.txt\",\"utf8\")!==\"verified-fixture\\n\")throw Error(\"mismatch\");console.log(\"PI-VALIDATED-fixture\")' (in_progress): node -e 'if(require(\"node:fs\").readFileSync(\"extended-fixture.txt\",\"utf8\")!==\"verified-fixture\\n\")throw Error(\"mismatch\");console.log(\"PI-VALIDATED-fixture\")'", + "output": "{\"content\":[{\"type\":\"text\",\"text\":\"PI-VALIDATED-fixture\\n\"}],\"details\":{}}", + "outputTruncated": false, + "executionId": "validate" + } + }, + { + "seq": 118, + "sourceSeq": 92, + "eventType": "tool.execution.completed", + "payload": { + "schema": "paperclip.tool.execution.v1", + "name": "bash", + "operation": "execute", + "transport": "builtin", + "status": "completed", + "target": null, + "exitCode": null, + "progress": null, + "output": "{\"content\":[{\"type\":\"text\",\"text\":\"PI-VALIDATED-fixture\\n\"}],\"structuredContent\":{\"output\":\"PI-VALIDATED-fixture\\n\",\"truncated\":false,\"exit_code\":0,\"wall_time_seconds\":0}}", + "outputTruncated": false, + "executionId": "validate" + } + } + ] +} diff --git a/tests/runner-e2e/fixtures/pi-native-restart-actual-prefix.json b/tests/runner-e2e/fixtures/pi-native-restart-actual-prefix.json new file mode 100644 index 0000000000..9f5707addd --- /dev/null +++ b/tests/runner-e2e/fixtures/pi-native-restart-actual-prefix.json @@ -0,0 +1,182 @@ +{ + "description": "Sanitized identity-only projection of Pi 1.0.0 profile13 pending-input stream; original submission omits turnId before turn.started assigns it. No restart was reached in the original failing attempt. Prompt, reasoning, timestamps and private execution metadata omitted.", + "state": { + "issue": { + "id": "issue", + "companyId": "company", + "status": "in_progress" + }, + "runs": [ + { + "id": "run", + "companyId": "company", + "nativeIssueId": "issue", + "runtimeMode": "native", + "status": "running", + "nativeSessionId": "session", + "runnerInstanceId": "runner" + } + ], + "interactions": [ + { + "id": "interaction", + "companyId": "company", + "issueId": "issue", + "kind": "ask_user_questions", + "status": "pending", + "result": null, + "sourceRunId": "run", + "continuationPolicy": "none", + "resolverPolicy": "human_only", + "idempotencyKey": "paperclip-runner-question:run:request", + "payload": { + "version": 1, + "title": "Pi native restart", + "submitLabel": "Submit answers", + "supersedeOnUserComment": false, + "questions": [ + { + "id": "answer", + "prompt": "Answer", + "helpText": "Pi native restart", + "selectionMode": "single", + "required": true, + "allowOther": true, + "options": [ + { + "id": "paperclip_text_answer", + "label": "Pi native restart", + "description": "Expected text input", + "freeText": true + } + ] + } + ], + "questionSet": { + "schema": "paperclip.question_set.v1", + "title": "Pi native restart", + "submitLabel": "Submit answers", + "questions": [ + { + "id": "answer", + "header": "Pi native restart", + "prompt": "Answer", + "required": true, + "answerMode": "text", + "textValidation": { + "inputType": "text" + } + } + ] + }, + "runtimeRequestId": "request" + } + } + ] + }, + "events": [ + { + "companyId": "company", + "runId": "run", + "seq": 25, + "protocolSchemaVersion": 1, + "eventType": "turn.submitted", + "payload": { + "prpEvent": { + "schema": "paperclip.prp.event.v1", + "schemaVersion": 1, + "sourceKind": "runner", + "eventType": "turn.submitted", + "runId": "run", + "normalizedSessionId": "session", + "sourceInstanceId": "runner", + "sourceSeq": 6, + "sourceEventId": "runner:run:6", + "payload": { + "text": "Sanitized fixture request" + } + } + } + }, + { + "companyId": "company", + "runId": "run", + "seq": 28, + "protocolSchemaVersion": 1, + "eventType": "turn.started", + "payload": { + "prpEvent": { + "schema": "paperclip.prp.event.v1", + "schemaVersion": 1, + "sourceKind": "runner", + "eventType": "turn.started", + "runId": "run", + "turnId": "turn", + "normalizedSessionId": "session", + "sourceInstanceId": "runner", + "sourceSeq": 7, + "sourceEventId": "runner:run:7", + "payload": { + "status": "inProgress" + } + } + } + }, + { + "companyId": "company", + "runId": "run", + "seq": 222, + "protocolSchemaVersion": 1, + "eventType": "runtime_request.created", + "payload": { + "prpEvent": { + "schema": "paperclip.prp.event.v1", + "schemaVersion": 1, + "sourceKind": "runner", + "eventType": "runtime_request.created", + "runId": "run", + "turnId": "turn", + "itemId": "item-run", + "normalizedSessionId": "session", + "sourceInstanceId": "runner", + "sourceSeq": 195, + "sourceEventId": "runner:run:195", + "payload": { + "request": { + "type": "input", + "input": { + "title": "Pi native restart", + "schema": "paperclip.question_set.v1", + "questions": [ + { + "id": "answer", + "header": "Pi native restart", + "prompt": "Answer", + "required": true, + "answerMode": "text", + "textValidation": { + "inputType": "text" + } + } + ], + "submitLabel": "Submit answers" + }, + "itemId": "item-run", + "origin": { + "method": "elicitation/create", + "adapter": "acpx-runtime-sidecar", + "provider": "pi" + }, + "prompt": "Pi native restart", + "schema": "paperclip.runtime_request.v2", + "status": "pending", + "turnId": "turn", + "requestId": "request", + "requestKind": "runtime" + } + } + } + } + } + ] +} diff --git a/tests/runner-e2e/harness-env.ts b/tests/runner-e2e/harness-env.ts index 51a0968404..52bbf8bbbc 100644 --- a/tests/runner-e2e/harness-env.ts +++ b/tests/runner-e2e/harness-env.ts @@ -1,3 +1,6 @@ +import { installedDaytonaPluginKeys } from "./installed-daytona-plugin.js"; +import { QUALIFIED_ACPX_PROFILES } from "../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js"; +import { installedCliKeys } from "./installed-cli.js"; import path from "node:path"; import { chatNeedsApiTools, isManagedHiringCase } from "./chat-cases.js"; import { CREDENTIAL_NAMES } from "./types.js"; @@ -114,12 +117,20 @@ export function buildRunnerE2EProcessEnvironment( const candidates = new Map(); for (const execution of executions) { const agent = execution.profile.qualificationCandidate; - if (!agent || agent === "cursor") continue; + if (!agent) continue; const admittedSuite = execution.suite.id === "extended-harnesses" - || execution.suite.id === "rich-acp-warm-continuity"; + || execution.suite.id === "rich-acp-warm-continuity" + || (execution.suite.id === "pi-native" && agent === "pi") + || (execution.suite.id === "pi-controls" && agent === "pi") + || (["cursor-native", "native-provider-loss"].includes(execution.suite.id) && agent === "cursor") + || (execution.suite.id === "copilot-protection" && agent === "copilot") + || (execution.suite.id === "native-active-stop" && (agent === "cursor" || agent === "copilot")); if (!admittedSuite || !execution.suite.manualOnly) { throw new Error("Candidate qualification requires an explicit provider qualification suite"); } + if (QUALIFIED_ACPX_PROFILES[agent].qualificationStatus !== "pending") { + continue; + } const prior = candidates.get(agent); if (prior !== undefined && prior !== execution.profile.model) throw new Error("Conflicting candidate models"); candidates.set(agent, execution.profile.model); @@ -169,7 +180,7 @@ export function buildPaperclipServerEnvironment( ]) { delete result[key]; } - for (const key of GENERATED_SERVER_SECRET_KEYS) delete result[key]; + for (const key of [...GENERATED_SERVER_SECRET_KEYS, ...installedCliKeys, ...installedDaytonaPluginKeys]) delete result[key]; Object.assign(result, overrides); return result; } diff --git a/tests/runner-e2e/installed-cli.test.ts b/tests/runner-e2e/installed-cli.test.ts new file mode 100644 index 0000000000..4020bafe8c --- /dev/null +++ b/tests/runner-e2e/installed-cli.test.ts @@ -0,0 +1,94 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, expect, it } from "vitest"; +import { runnerMatrix } from "./catalog.js"; +import { assertInstalledCliSelection, installedCliKeys, verifyInstalledCli } from "./installed-cli.js"; +import { buildPaperclipServerEnvironment, buildRunnerE2EProcessEnvironment } from "./harness-env.js"; +const cells = runnerMatrix.filter(e => e.profile.id === "runner-acpx-pi"); +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); +const hash = (value: string) => createHash("sha256").update(value).digest("hex"); +async function fixture() { + const root = await realpath(await mkdtemp(join(tmpdir(), "e2e-installed-cli-"))); roots.push(root); + const cli = join(root, "node_modules/paperclipai"); const server = join(root, "node_modules/@paperclipai/server"); + await mkdir(join(cli, "dist"), { recursive: true }); await mkdir(join(server, "dist"), { recursive: true }); + await writeFile(join(cli, "package.json"), JSON.stringify({ name: "paperclipai", version: "1.2.3", type: "module", bin: { paperclipai: "./dist/index.js" } })); + await writeFile(join(server, "package.json"), JSON.stringify({ name: "@paperclipai/server", version: "1.2.3", type: "module", exports: { ".": { import: "./dist/index.js" } } })); + await writeFile(join(cli, "dist/index.js"), "// installed public CLI"); await writeFile(join(server, "dist/index.js"), "// installed public server"); + const env = { PAPERCLIP_RUNNER_E2E_INSTALLED_CLI: join(cli, "dist/index.js"), PAPERCLIP_RUNNER_E2E_INSTALLED_CLI_SHA256: hash("// installed public CLI"), PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_ROOT: server, PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_SHA256: hash("// installed public server") }; + return { root, cli, server, env }; +} +it("admits all26 current Pi cases through pinned installed public CLI/server with no overrides", async () => { + const { env, server } = await fixture(); expect(cells).toHaveLength(26); + for (const cell of cells) expect(await verifyInstalledCli(env, [cell])).toMatchObject({ serverRoot: server, defaultRuntimeResolution: true, qualificationOverride: false }); +}); +it("qualified providers drop ambient admission while pending Copilot retains its exact gate", () => { + for (const cell of cells) expect(buildRunnerE2EProcessEnvironment({ PAPERCLIP_RUNNER_ACPX_QUALIFICATION: "ambient" }, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined(); + for (const agent of ["copilot"]) { + const cell = runnerMatrix.find(e => e.profile.qualificationCandidate === agent)!; + expect(JSON.parse(buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION!)).toEqual([{ agent, model: cell.profile.model }]); + } + const changed = { ...cells[0]!, profile: { ...cells[0]!.profile, model: "custom/explicit-model" } }; + expect(buildRunnerE2EProcessEnvironment({}, [changed]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined(); +}); +it("rejects partial authority, unsupported cells and every runtime/path override", async () => { + const { env } = await fixture(); + for (const key of installedCliKeys) { const changed = { ...env, [key]: undefined }; await expect(verifyInstalledCli(changed, [cells[0]!])).rejects.toThrow("complete pins"); } + expect(() => assertInstalledCliSelection(env, [])).toThrow("explicit supported Pi"); + expect(() => assertInstalledCliSelection(env, [runnerMatrix.find(e => e.profile.qualificationCandidate === "cursor")!])).toThrow("explicit supported Pi"); + for (const key of ["PAPERCLIP_RUNNER_BINARY", "PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", "PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH", "PAPERCLIP_RUNNER_ACPX_QUALIFICATION", "PAPERCLIP_ACPX_BUILTIN_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", "NODE_OPTIONS", "NODE_PATH"]) await expect(verifyInstalledCli({ ...env, [key]: "foreign" }, [cells[0]!])).rejects.toThrow("forbids"); +}); +it("rejects stale bytes and another server root instead of falling back to source", async () => { + const f = await fixture(); const other = await fixture(); + await expect(verifyInstalledCli({ ...f.env, PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_ROOT: other.server }, [cells[0]!])).rejects.toThrow("foreign server"); + await expect(verifyInstalledCli({ ...f.env, PAPERCLIP_RUNNER_E2E_INSTALLED_CLI_SHA256: "0".repeat(64) }, [cells[0]!])).rejects.toThrow("reviewed pins"); + await writeFile(join(f.server, "dist/index.js"), "changed"); + await expect(verifyInstalledCli(f.env, [cells[0]!])).rejects.toThrow("reviewed pins"); +}); +it("rejects linked entrypoints and source-export package identities", async () => { + const f = await fixture(); const other = await fixture(); + await rm(join(f.cli, "dist/index.js")); await symlink(join(other.cli, "dist/index.js"), join(f.cli, "dist/index.js")); + await expect(verifyInstalledCli(f.env, [cells[0]!])).rejects.toThrow("canonical and unlinked"); + await rm(join(f.cli, "dist/index.js")); await writeFile(join(f.cli, "dist/index.js"), "// installed public CLI"); + await writeFile(join(f.server, "package.json"), JSON.stringify({ name: "@paperclipai/server", version: "1.2.3", exports: { ".": "./src/index.ts" } })); + await expect(verifyInstalledCli(f.env, [cells[0]!])).rejects.toThrow("public package identity"); +}); +it("keeps installed proof inputs out of the production server environment", async () => { + const { env } = await fixture(); const server = buildPaperclipServerEnvironment(env); + for (const key of installedCliKeys) expect(server[key]).toBeUndefined(); + await expect(verifyInstalledCli({}, [])).resolves.toBeUndefined(); +}); + +it.skipIf(process.platform === "win32")("rejects a FIFO entrypoint without waiting for a writer", async () => { + const f = await fixture(); await rm(f.env.PAPERCLIP_RUNNER_E2E_INSTALLED_CLI); + execFileSync("/usr/bin/mkfifo", [f.env.PAPERCLIP_RUNNER_E2E_INSTALLED_CLI], { timeout: 5000, env: {} }); + await expect(verifyInstalledCli(f.env, [cells[0]!])).rejects.toThrow("bounded regular file"); +}); + +it("uses the direct public Playwright JS bin without bin-shim Node injection", async () => { + const { runnerE2EPlaywrightInvocation } = await import("./web-server-command.js"); + const f = await fixture(); const cli = join(f.root,"node_modules/@playwright/test/cli.js"); + await mkdir(join(f.root,"node_modules/@playwright/test"),{recursive:true}); + await writeFile(cli,"console.log(JSON.stringify({nodePath:process.env.NODE_PATH??null,nodeOptions:process.env.NODE_OPTIONS??null,args:process.argv.slice(2)}))"); + const invocation = runnerE2EPlaywrightInvocation(f.root,["--version"],true); + expect(invocation.command).toBe(process.execPath); + expect(JSON.parse(execFileSync(invocation.command,invocation.args,{env:{},timeout:5000,encoding:"utf8"}))).toEqual({nodePath:null,nodeOptions:null,args:["--version"]}); + expect(runnerE2EPlaywrightInvocation(f.root,["--version"],false)).toEqual({command:"pnpm",args:["exec","playwright","--version"]}); + for (const key of ["NODE_PATH","NODE_OPTIONS"]) expect(()=>assertInstalledCliSelection({...f.env,[key]:""},[cells[0]!])).toThrow("ambient Node injection"); +}); + +it("startup-only accepts only explicit uncredentialed local hello and zero retries", async () => { + const { assertInstalledStartupOnly } = await import("./installed-cli.js"); + const { parseRunnerSelectors } = await import("./selectors.js"); + const f=await fixture(); const cell=cells.find(e=>e.id==="extended-harnesses.runner-acpx-pi.local.hello-complete")!; + const options=parseRunnerSelectors(["--installed-startup-only","--id",cell.id,"--max-automatic-retries","0"]); + expect(options.installedStartupOnly).toBe(true);expect(()=>assertInstalledStartupOnly(f.env,[cell],options)).not.toThrow(); + for(const delta of [{maxAutomaticRetries:1},{maxParallel:2},{ui:true},{debug:true},{headed:true},{list:true},{matrixJson:true},{ids:[]}])expect(()=>assertInstalledStartupOnly(f.env,[cell],{...options,...delta})).toThrow("startup-only"); + expect(()=>assertInstalledStartupOnly(f.env,[cells.find(e=>e.environment.id==="daytona")!],options)).toThrow("startup-only"); + expect(()=>assertInstalledStartupOnly({...f.env,OPENROUTER_API_KEY:"dummy-not-a-real-key"},[cell],options)).toThrow("credential inputs"); + expect(()=>assertInstalledStartupOnly({...f.env,KIMI_MODEL_API_KEY:"dummy-not-a-real-key"},[cell],options)).toThrow("credential inputs"); + expect(()=>assertInstalledStartupOnly({},[cell],options)).toThrow("startup-only"); +}); diff --git a/tests/runner-e2e/installed-cli.ts b/tests/runner-e2e/installed-cli.ts new file mode 100644 index 0000000000..97dc50a49f --- /dev/null +++ b/tests/runner-e2e/installed-cli.ts @@ -0,0 +1,59 @@ +/** Optional published-install lane. No production hook or admission override. */ +import { constants } from "node:fs"; +import { lstat, open, realpath } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { findPackageJSON } from "node:module"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { CREDENTIAL_NAMES, type MatrixExecution } from "./types.js"; + +export const installedCliKeys = ["PAPERCLIP_RUNNER_E2E_INSTALLED_CLI", "PAPERCLIP_RUNNER_E2E_INSTALLED_CLI_SHA256", "PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_ROOT", "PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_SHA256"] as const; +const overrideKeys = ["PAPERCLIP_RUNNER_BINARY", "PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", "PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH", "PAPERCLIP_RUNNER_ACPX_QUALIFICATION", "PAPERCLIP_ACPX_BUILTIN_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST"] as const; +const suites = new Set(["extended-harnesses", "pi-native", "pi-controls", "rich-acp-warm-continuity"]); +export function usesInstalledCli(env: NodeJS.ProcessEnv): boolean { + return installedCliKeys.some(key => env[key] !== undefined); +} +export function assertInstalledCliSelection(env: NodeJS.ProcessEnv, executions: readonly MatrixExecution[]): void { + if (!usesInstalledCli(env)) return; + if (!installedCliKeys.every(key => Boolean(env[key])) || executions.length === 0 || executions.some(e => e.profile.id !== "runner-acpx-pi" || e.profile.qualificationCandidate !== "pi" || !e.suite.manualOnly || !suites.has(e.suite.id))) throw new Error("Installed CLI proof requires complete pins and explicit supported Pi cases"); + for (const key of overrideKeys) if (env[key] !== undefined) throw new Error(`Installed CLI proof forbids ${key}`); + if (env.NODE_OPTIONS !== undefined || env.NODE_PATH !== undefined) throw new Error("Installed CLI proof forbids ambient Node injection"); +} +async function readOwned(path: string, maximum: number): Promise { + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const before = await handle.stat({ bigint: true }); + if (!before.isFile() || before.size < 1n || before.size > BigInt(maximum)) throw new Error("Installed CLI proof requires a bounded regular file"); + const bytes = await handle.readFile(); const after = await handle.stat({ bigint: true }); const named = await lstat(path, { bigint: true }); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs || named.dev !== before.dev || named.ino !== before.ino || named.isSymbolicLink() || bytes.length !== Number(before.size)) throw new Error("Installed CLI proof file changed during admission"); + return bytes; + } finally { await handle.close(); } +} +export async function verifyInstalledCli(env: NodeJS.ProcessEnv, executions: readonly MatrixExecution[]) { + if (!usesInstalledCli(env)) return undefined; + assertInstalledCliSelection(env, executions); + const entry = env.PAPERCLIP_RUNNER_E2E_INSTALLED_CLI!; + const serverRoot = env.PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_ROOT!; + for (const path of [entry, serverRoot]) if (!isAbsolute(path) || resolve(path) !== path || await realpath(path) !== path) throw new Error("Installed CLI proof paths must be canonical and unlinked"); + if (!entry.endsWith("/dist/index.js")) throw new Error("Installed CLI proof requires published dist/index.js"); + const cliRoot = resolve(dirname(entry), ".."); + const cli = JSON.parse((await readOwned(join(cliRoot, "package.json"), 65536)).toString()); + const manifest = findPackageJSON("@paperclipai/server", pathToFileURL(entry)); + if (!manifest || await realpath(manifest) !== join(serverRoot, "package.json")) throw new Error("Installed CLI resolved a foreign server dependency"); + const server = JSON.parse((await readOwned(join(serverRoot, "package.json"), 65536)).toString()); + if (cli.name !== "paperclipai" || cli.bin?.paperclipai !== "./dist/index.js" || server.name !== "@paperclipai/server" || typeof cli.version !== "string" || cli.version !== server.version || server.exports?.["."]?.import !== "./dist/index.js") throw new Error("Installed CLI/server public package identity differs"); + const serverEntry = join(serverRoot, "dist/index.js"); + if (await realpath(serverEntry) !== serverEntry) throw new Error("Installed server entrypoint escapes its package"); + const digest = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex"); + const cliSha256 = digest(await readOwned(entry, 64 * 1024 * 1024)); + const serverSha256 = digest(await readOwned(serverEntry, 16 * 1024 * 1024)); + if (cliSha256 !== env.PAPERCLIP_RUNNER_E2E_INSTALLED_CLI_SHA256 || serverSha256 !== env.PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_SHA256) throw new Error("Installed CLI/server bytes differ from their reviewed pins"); + return { schema: "paperclip.e2e.installed-cli-admission/v1", entry, cliRoot, cliSha256, serverRoot, serverEntry, serverSha256, version: cli.version, defaultRuntimeResolution: true, qualificationOverride: false }; +} + +/** This prep lane cannot create agents or read the normal local credential file. */ +export function assertInstalledStartupOnly(env: NodeJS.ProcessEnv, executions: readonly MatrixExecution[], options: { ids: string[]; maxParallel: number; maxAutomaticRetries: number; headed: boolean; ui: boolean; debug: boolean; list: boolean; matrixJson: boolean }) { + assertInstalledCliSelection(env, executions); + if (!usesInstalledCli(env) || executions.length !== 1 || executions[0]?.id !== "extended-harnesses.runner-acpx-pi.local.hello-complete" || options.ids.length !== 1 || options.ids[0] !== executions[0]?.id || options.maxParallel !== 1 || options.maxAutomaticRetries !== 0 || options.headed || options.ui || options.debug || options.list || options.matrixJson) throw new Error("Installed startup-only requires one explicit local Pi hello, no retries or interactive mode"); + if (CREDENTIAL_NAMES.some(key => env[key] !== undefined) || Object.keys(env).some(key => /^(?:OPENAI|ANTHROPIC|OPENROUTER|DAYTONA|XAI|GROK|CURSOR|COPILOT|GITHUB|GH)(?:_|$)/.test(key) && env[key] !== undefined)) throw new Error("Installed startup-only forbids provider credential inputs"); +} diff --git a/tests/runner-e2e/installed-daytona-plugin.test.ts b/tests/runner-e2e/installed-daytona-plugin.test.ts new file mode 100644 index 0000000000..0a6b8e7679 --- /dev/null +++ b/tests/runner-e2e/installed-daytona-plugin.test.ts @@ -0,0 +1,96 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, expect, it, vi } from "vitest"; +import { runnerMatrix } from "./catalog.js"; +import { installedDaytonaPluginKeys, verifyInstalledDaytonaPlugin, type InstalledDaytonaPluginAuthority } from "./installed-daytona-plugin.js"; +import { buildPaperclipServerEnvironment } from "./harness-env.js"; +import { setupLiveFixtures } from "./live-fixtures.js"; +import type { RunnerApi } from "./api.js"; +const cell = runnerMatrix.find(e => e.id === "extended-harnesses.runner-acpx-pi.daytona.hello-complete")!; +const sdkVersion: string = JSON.parse(readFileSync(new URL("../../packages/plugins/sdk/package.json", import.meta.url), "utf8")).version; +const roots: string[] = []; +const hash = (v: string) => createHash("sha256").update(v).digest("hex"); +afterEach(async () => { vi.unstubAllEnvs(); 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(), "e2e-installed-plugin-"))); roots.push(root); + async function pkg(name: string, version: string, dependencies: Record = {}, extra = {}) { + const dir = join(root, "node_modules", name); const entry = name === "@daytonaio/sdk" ? "./esm/index.js" : "./dist/index.js"; await mkdir(join(dir, name === "@daytonaio/sdk" ? "esm" : "dist"), { recursive: true }); + const content = JSON.stringify({ name, version, type: "module", exports: { ".": { import: name === "@daytonaio/sdk" ? { types: "./esm/index.d.ts", default: entry } : entry } }, dependencies, ...extra }); + await writeFile(join(dir, "package.json"), content); await writeFile(join(dir, entry), "// compiled"); + return { root: dir, packageSha256: hash(content), entry, entrySha256: hash("// compiled") }; + } + const plugin = await pkg("@paperclipai/plugin-daytona", "0.1.1", { "@paperclipai/plugin-sdk": sdkVersion, "@daytonaio/sdk": "0.203.0" }, { paperclipPlugin: { manifest: "./dist/manifest.js", worker: "./dist/worker.js" } }); + await writeFile(join(plugin.root, "dist/manifest.js"), "// manifest"); await writeFile(join(plugin.root, "dist/worker.js"), "// worker"); + const authority: InstalledDaytonaPluginAuthority = { schema: "paperclip.e2e.installed-daytona-plugin/v1", graphRoot: root, plugin: { ...plugin, manifestSha256: hash("// manifest"), workerSha256: hash("// worker") }, sdk: await pkg("@paperclipai/plugin-sdk", sdkVersion, { "@paperclipai/shared": "0.3.1" }), shared: await pkg("@paperclipai/shared", "0.3.1"), daytona: await pkg("@daytonaio/sdk", "0.203.0") }; + const authorityPath = join(root, "authority.json"); + const env: NodeJS.ProcessEnv = { PAPERCLIP_RUNNER_E2E_INSTALLED_CLI: "/reviewed/cli", PAPERCLIP_RUNNER_E2E_INSTALLED_CLI_SHA256: "reviewed-separately", PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_ROOT: "/reviewed/server", PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_SHA256: "reviewed-separately", PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN: plugin.root, PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_AUTHORITY: authorityPath }; + async function seal() { const bytes = JSON.stringify(authority); await writeFile(authorityPath, bytes); env.PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_AUTHORITY_SHA256 = hash(bytes); } + await seal(); return { root, authority, env, seal }; +} +it("admits pinned published plugin and resolved SDK graph without claiming a full graph audit", async () => { + const f = await fixture(); expect(await verifyInstalledDaytonaPlugin(f.env, [cell])).toMatchObject({ packageRoot: f.authority.plugin.root, sourceFallback: false, fullGraphVerified: false, completeGraphAuthorityRequired: true }); +}); +it("fails closed for absent/partial installed Daytona pins but preserves ordinary source and local lanes", async () => { + const f = await fixture(); + for (const key of installedDaytonaPluginKeys) await expect(verifyInstalledDaytonaPlugin({ ...f.env, [key]: undefined }, [cell])).rejects.toThrow("complete pins"); + await expect(verifyInstalledDaytonaPlugin({}, [cell])).resolves.toBeUndefined(); + const local = runnerMatrix.find(e => e.id === "extended-harnesses.runner-acpx-pi.local.hello-complete")!; + await expect(verifyInstalledDaytonaPlugin(f.env, [local])).rejects.toThrow("Daytona selection"); + const noPlugin = { ...f.env }; for (const key of installedDaytonaPluginKeys) delete noPlugin[key]; + await expect(verifyInstalledDaytonaPlugin(noPlugin, [local])).resolves.toBeUndefined(); + await expect(verifyInstalledDaytonaPlugin(noPlugin, [cell])).rejects.toThrow("complete pins"); +}); +it("rejects stale authority, worker, manifest and dependency entry bytes", async () => { + const f = await fixture(); + await expect(verifyInstalledDaytonaPlugin({ ...f.env, PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_AUTHORITY_SHA256: "0".repeat(64) }, [cell])).rejects.toThrow("reviewed pins"); + for (const [path, original] of [[join(f.authority.plugin.root,"dist/worker.js"),"// worker"], [join(f.authority.plugin.root,"dist/manifest.js"),"// manifest"], [join(f.authority.sdk.root,"dist/index.js"),"// compiled"]]) { + await writeFile(path!, "// wrong"); await expect(verifyInstalledDaytonaPlugin(f.env,[cell])).rejects.toThrow("reviewed pins"); await writeFile(path!,original!); + } +}); +it("rejects source exports and workspace dependency versions even with refreshed pins", async () => { + const f = await fixture(); const p = join(f.authority.plugin.root,"package.json"); + const original = { name:"@paperclipai/plugin-daytona",version:"0.1.1",exports:{".":{import:"./dist/index.js"}},paperclipPlugin:{manifest:"./dist/manifest.js",worker:"./dist/worker.js"},dependencies:{"@paperclipai/plugin-sdk":sdkVersion,"@daytonaio/sdk":"0.203.0"} }; + for (const manifest of [{ ...original, exports:{".":{import:"./src/index.ts"}} }, { ...original, dependencies:{ ...original.dependencies,"@paperclipai/plugin-sdk":"workspace:*" } }]) { + const bytes=JSON.stringify(manifest);await writeFile(p,bytes);f.authority.plugin.packageSha256=hash(bytes);await f.seal();await expect(verifyInstalledDaytonaPlugin(f.env,[cell])).rejects.toThrow(/compiled package exports|dependency identity/); + } +}); +it("rejects linked or escaped roots and a shadowed foreign resolved dependency", async () => { + const f = await fixture(); const other = await fixture(); + f.authority.shared=other.authority.shared;await f.seal();await expect(verifyInstalledDaytonaPlugin(f.env,[cell])).rejects.toThrow("escapes"); + f.authority.shared={...other.authority.shared,root:join(f.root,"node_modules/@paperclipai/shared")};await f.seal(); + const nested=join(f.authority.plugin.root,"node_modules/@paperclipai");await mkdir(nested,{recursive:true});await symlink(other.authority.sdk.root,join(nested,"plugin-sdk")); + await expect(verifyInstalledDaytonaPlugin(f.env,[cell])).rejects.toThrow("foreign dependency"); + await rm(nested,{recursive:true});await rm(f.authority.shared.root,{recursive:true});await symlink(other.authority.shared.root,f.authority.shared.root); + await expect(verifyInstalledDaytonaPlugin(f.env,[cell])).rejects.toThrow("canonical and unlinked"); +}); +it.skipIf(process.platform === "win32")("rejects a FIFO authority without waiting for a writer", async () => { + const f=await fixture();const p=f.env.PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_AUTHORITY!;await rm(p);execFileSync("/usr/bin/mkfifo",[p],{timeout:5000,env:{}});await expect(verifyInstalledDaytonaPlugin(f.env,[cell])).rejects.toThrow("bounded unlinked regular file"); +}); +it("uses the verified public package in the existing API and rejects changed bytes before any mutation", async () => { + // This positive fixture models the sanitized installed launcher, not pnpm's parent environment. + vi.stubEnv("NODE_OPTIONS", undefined); vi.stubEnv("NODE_PATH", undefined); + const f=await fixture(); for(const [k,v]of Object.entries(f.env))vi.stubEnv(k,v!); + const calls:unknown[]=[]; + const api={async post(url:string,data:unknown){calls.push({url,data});throw new Error("stop-after-public-install");}} as unknown as RunnerApi; + const input={api,execution:cell,executionNonce:"nonce",workspacePath:"/tmp/workspace",credentials:{},daytonaImage:"ghcr.io/example/image@sha256:"+"a".repeat(64)}; + await expect(setupLiveFixtures(input)).rejects.toThrow("stop-after-public-install"); + expect(calls).toEqual([{url:"/api/plugins/install",data:{packageName:f.authority.plugin.root,isLocalPath:true}}]); + calls.length=0;await writeFile(join(f.authority.plugin.root,"dist/worker.js"),"// changed");await expect(setupLiveFixtures(input)).rejects.toThrow("reviewed pins");expect(calls).toEqual([]); +}); +it.each(["NODE_OPTIONS", "NODE_PATH"])("rejects ambient %s before the plugin install API", async (key) => { + const f = await fixture(); + for (const [name, value] of Object.entries(f.env)) vi.stubEnv(name, value!); + vi.stubEnv("NODE_OPTIONS", undefined); vi.stubEnv("NODE_PATH", undefined); + const post = vi.fn(); + const api = { post } as unknown as RunnerApi; + for (const value of ["", "foreign"]) { + vi.stubEnv(key, value); + await expect(setupLiveFixtures({ api, execution: cell, executionNonce: "nonce", workspacePath: "/tmp/workspace", credentials: {} })).rejects.toThrow("ambient Node injection"); + expect(post).not.toHaveBeenCalled(); + } +}); +it("keeps fixture authority out of the production server environment", async()=>{const f=await fixture();const env=buildPaperclipServerEnvironment(f.env);for(const k of installedDaytonaPluginKeys)expect(env[k]).toBeUndefined();}); diff --git a/tests/runner-e2e/installed-daytona-plugin.ts b/tests/runner-e2e/installed-daytona-plugin.ts new file mode 100644 index 0000000000..b23742f692 --- /dev/null +++ b/tests/runner-e2e/installed-daytona-plugin.ts @@ -0,0 +1,72 @@ +/** Public plugin fixture admission; the complete installed graph is audited separately. */ +import { constants } from "node:fs"; +import { lstat, open, realpath } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { findPackageJSON } from "node:module"; +import { isAbsolute, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { assertInstalledCliSelection, usesInstalledCli } from "./installed-cli.js"; +import type { MatrixExecution } from "./types.js"; + +export const installedDaytonaPluginKeys = ["PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN", "PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_AUTHORITY", "PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_AUTHORITY_SHA256"] as const; +type PackagePin = { root: string; packageSha256: string; entry: string; entrySha256: string }; +export interface InstalledDaytonaPluginAuthority { + schema: "paperclip.e2e.installed-daytona-plugin/v1"; + graphRoot: string; + plugin: PackagePin & { manifestSha256: string; workerSha256: string }; + sdk: PackagePin; + shared: PackagePin; + daytona: PackagePin; +} +const digest = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex"); +async function canonical(path: string) { + if (typeof path !== "string" || !isAbsolute(path) || resolve(path) !== path || await realpath(path) !== path) throw new Error("Installed Daytona plugin path must be canonical and unlinked"); +} +async function pinned(path: string, expected: string, maximum = 32 * 1024 * 1024) { + if (!/^[a-f0-9]{64}$/.test(expected)) throw new Error("Installed Daytona plugin requires a SHA-256 pin"); + await canonical(path); + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const before = await handle.stat({ bigint: true }); + if (!before.isFile() || before.size < 1n || before.size > BigInt(maximum) || before.nlink !== 1n) throw new Error("Installed Daytona plugin requires a bounded unlinked regular file"); + const bytes = await handle.readFile(); const after = await handle.stat({ bigint: true }); const named = await lstat(path, { bigint: true }); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs || named.dev !== before.dev || named.ino !== before.ino || bytes.length !== Number(before.size) || digest(bytes) !== expected) throw new Error("Installed Daytona plugin bytes differ from reviewed pins"); + return bytes; + } finally { await handle.close(); } +} +function compiledEntry(manifest: any): string | undefined { + const exported = manifest.exports?.["."]; + return typeof exported === "string" ? exported : (typeof exported?.import === "string" ? exported.import : exported?.import?.default) ?? manifest.main; +} +export async function verifyInstalledDaytonaPlugin(env: NodeJS.ProcessEnv, executions: readonly MatrixExecution[]) { + const selected = installedDaytonaPluginKeys.some(key => env[key] !== undefined); + const required = usesInstalledCli(env) && executions.some(e => e.environment.id === "daytona"); + if (!selected && !required) return undefined; + assertInstalledCliSelection(env, executions); + if (!usesInstalledCli(env) || !installedDaytonaPluginKeys.every(key => Boolean(env[key])) || executions.some(e => e.environment.id !== "daytona")) throw new Error("Installed Daytona plugin requires complete pins and installed Pi Daytona selection"); + const authorityPath = env.PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_AUTHORITY!; + const authoritySha256 = env.PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_AUTHORITY_SHA256!; + const a: InstalledDaytonaPluginAuthority = JSON.parse((await pinned(authorityPath, authoritySha256, 65536)).toString()); + if (a.schema !== "paperclip.e2e.installed-daytona-plugin/v1" || a.plugin?.root !== env.PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN) throw new Error("Installed Daytona plugin authority differs"); + await canonical(a.graphRoot); + async function packageAt(pin: PackagePin, name: string) { + await canonical(pin.root); + if (pin.root !== join(a.graphRoot, "node_modules", name)) throw new Error("Installed Daytona plugin package escapes its reviewed graph"); + const manifest = JSON.parse((await pinned(join(pin.root, "package.json"), pin.packageSha256, 65536)).toString()); + if (manifest.name !== name || typeof manifest.version !== "string" || !/^\d+\.\d+\.\d+(?:[-+][a-zA-Z0-9.-]+)?$/.test(manifest.version) || pin.entry !== (name === "@daytonaio/sdk" ? "./esm/index.js" : "./dist/index.js") || compiledEntry(manifest) !== pin.entry) throw new Error("Installed Daytona plugin requires published compiled package exports"); + await pinned(join(pin.root, pin.entry), pin.entrySha256); + return manifest; + } + const plugin = await packageAt(a.plugin, "@paperclipai/plugin-daytona"); + const sdk = await packageAt(a.sdk, "@paperclipai/plugin-sdk"); + const shared = await packageAt(a.shared, "@paperclipai/shared"); + const daytona = await packageAt(a.daytona, "@daytonaio/sdk"); + if (plugin.paperclipPlugin?.manifest !== "./dist/manifest.js" || plugin.paperclipPlugin?.worker !== "./dist/worker.js" || plugin.dependencies?.["@paperclipai/plugin-sdk"] !== sdk.version || sdk.dependencies?.["@paperclipai/shared"] !== shared.version || plugin.dependencies?.["@daytonaio/sdk"] !== "0.203.0" || daytona.version !== "0.203.0") throw new Error("Installed Daytona plugin public dependency identity differs"); + await pinned(join(a.plugin.root, "dist/manifest.js"), a.plugin.manifestSha256); + await pinned(join(a.plugin.root, "dist/worker.js"), a.plugin.workerSha256); + for (const [name, parent, pin] of [["@paperclipai/plugin-sdk", join(a.plugin.root, "dist/worker.js"), a.sdk], ["@paperclipai/shared", join(a.sdk.root, a.sdk.entry), a.shared], ["@daytonaio/sdk", join(a.plugin.root, "dist/worker.js"), a.daytona]] as const) { + const found = findPackageJSON(name, pathToFileURL(parent)); + if (!found || await realpath(found) !== join(pin.root, "package.json")) throw new Error("Installed Daytona plugin resolves a foreign dependency"); + } + return { schema: a.schema, authorityPath, authoritySha256, packageRoot: a.plugin.root, graphRoot: a.graphRoot, fullGraphVerified: false, completeGraphAuthorityRequired: true, sourceFallback: false }; +} diff --git a/tests/runner-e2e/installed-startup.spec.ts b/tests/runner-e2e/installed-startup.spec.ts new file mode 100644 index 0000000000..c8c9ee07e1 --- /dev/null +++ b/tests/runner-e2e/installed-startup.spec.ts @@ -0,0 +1,31 @@ +/** Credential-free proof of the real launcher/Playwright/WebServer chain. */ +import { test, expect } from "@playwright/test"; +import { writeFile } from "node:fs/promises"; +import path from "node:path"; + +test("installed controller serves health and UI without creating provider work", async ({ page, request, baseURL }) => { + test.skip(process.env.PAPERCLIP_RUNNER_E2E_INSTALLED_STARTUP_ONLY !== "1"); + expect(baseURL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + await page.route("**/*", route => { + const url = new URL(route.request().url()); + return url.origin === baseURL ? route.continue() : route.abort("blockedbyclient"); + }); + const health = await request.get(`${baseURL}/api/health`); + expect(health.ok()).toBe(true); + const before = await request.get(`${baseURL}/api/companies`); + expect(before.ok()).toBe(true); expect(await before.json()).toEqual([]); + const response = await page.goto(baseURL!); + expect(response?.ok()).toBe(true); + await expect(page.locator("#root")).not.toBeEmpty(); + await expect(page.getByRole("heading", { name: "What is the name of your organization?", exact: true })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Name", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeDisabled(); + expect(errors).toEqual([]); + const after = await request.get(`${baseURL}/api/companies`); + expect(after.ok()).toBe(true); expect(await after.json()).toEqual([]); + const output = process.env.PAPERCLIP_RUNNER_E2E_PRIVATE_DIR!; + await page.screenshot({ path: path.join(output, "installed-startup-only.png"), fullPage: true }); + await writeFile(path.join(output, "installed-startup-only.json"), `${JSON.stringify({ status: "health_ui_zero_company_passed", qualified: false, providerCalls: 0, healthStatus: health.status(), uiStatus: response!.status(), companiesBefore: 0, companiesAfter: 0, pageErrors: errors }, null, 2)}\n`, { mode: 0o600 }); +}); diff --git a/tests/runner-e2e/launch.ts b/tests/runner-e2e/launch.ts index 0ad54f51ab..f2ee17b90a 100644 --- a/tests/runner-e2e/launch.ts +++ b/tests/runner-e2e/launch.ts @@ -1,4 +1,8 @@ +import { runnerE2EPlaywrightInvocation } from "./web-server-command.js"; +import { verifyInstalledDaytonaPlugin } from "./installed-daytona-plugin.js"; +import { assertInstalledCliSelection, assertInstalledStartupOnly, verifyInstalledCli } from "./installed-cli.js"; import { createProcessTreeOwner, stopOwnedProcessTree } from "./process-tree-owner.js"; +import { createRunnerE2ETemporaryRoot } from "./server-config.js"; import { randomBytes } from "node:crypto"; import { prepareCodexCiSandbox, requiresCodexCiSandbox } from "./codex-ci-sandbox.js"; import { spawn } from "node:child_process"; @@ -12,8 +16,8 @@ import { cp, lstat, mkdir, - mkdtemp, readFile, + realpath, readdir, rm, symlink, @@ -262,7 +266,7 @@ async function prepareProviderPath( } async function runProcess( - args: string[], + invocation: { command: string; args: string[] }, env: NodeJS.ProcessEnv, timeoutMs: number | null, logPath: string, @@ -270,7 +274,7 @@ async function runProcess( interactive: boolean, ) { const log = createWriteStream(logPath, { flags: "a", mode: 0o600 }); - const child = spawn("pnpm", args, { + const child = spawn(invocation.command, invocation.args, { cwd: repositoryRoot, env, stdio: ["inherit", "pipe", "pipe"], @@ -477,15 +481,18 @@ async function runAttempt(input: { "One isolated runner E2E harness cannot mix profiles or environments", ); } + assertInstalledCliSelection(process.env, executions); + const installedCli = await verifyInstalledCli(process.env, executions); + const installedPlugin = await verifyInstalledDaytonaPlugin(process.env, executions); const startedAtMs = Date.now(); const sharedMemoryBaseline = snapshotDarwinSharedMemory(); - const temporaryRoot = await mkdtemp( - path.join(os.tmpdir(), "paperclip-runner-e2e-"), - ); + const temporaryParent = await realpath(os.tmpdir()); + const temporaryRoot = await createRunnerE2ETemporaryRoot(temporaryParent); const publishedResults: RunnerE2EResult[] = []; const publishedResultPaths = new Map(); let attemptSecrets: string[] = []; let processCleanupFailed = false; + let startupReceiptPath: string | undefined; const rawCleanupResults: Array<{ cleanup: string; synthetic: boolean; status: string }> = []; try { const paperclipHome = path.join(temporaryRoot, "paperclip-home"); @@ -523,7 +530,9 @@ async function runAttempt(input: { betterAuthSecret, ]); attemptSecrets = credentials; - const runnerBinary = resolvePaperclipRunnerBinaryForHarness( + if (installedCli) await writeFile(path.join(privateDir, "installed-cli-admission.json"), `${JSON.stringify(installedCli, null, 2)}\n`, { mode: 0o600 }); + if (installedPlugin) await writeFile(path.join(privateDir, "installed-daytona-plugin-admission.json"), `${JSON.stringify(installedPlugin, null, 2)}\n`, { mode: 0o600 }); + const runnerBinary = installedCli ? undefined : resolvePaperclipRunnerBinaryForHarness( executions, repositoryRoot, ); @@ -534,6 +543,7 @@ async function runAttempt(input: { executions.map((candidate) => candidate.id), ), PAPERCLIP_RUNNER_E2E_ATTEMPT: String(attempt), + PAPERCLIP_RUNNER_E2E_INSTALLED_STARTUP_ONLY: options.installedStartupOnly ? "1" : undefined, PAPERCLIP_RUNNER_E2E_PUBLIC_MCP: executions.some(candidate => candidate.task.flow === "public_mcp") ? "1" : "0", PAPERCLIP_RUNNER_E2E_PORT: String(port), PAPERCLIP_RUNNER_E2E_TEMP_ROOT: temporaryRoot, @@ -542,7 +552,7 @@ async function runAttempt(input: { PAPERCLIP_RUNNER_E2E_SERVER_LOG: path.join(privateDir, "server.log"), PAPERCLIP_RUNNER_BINARY: runnerBinary, PAPERCLIP_RUNNER_REMOTE_BINARY_PATH: - resolvePaperclipRemoteRunnerBinaryForHarness(executions, runnerBinary), + installedCli ? undefined : resolvePaperclipRemoteRunnerBinaryForHarness(executions, runnerBinary), // Vite's optimized dependency cache embeds revision query strings. A // private per-attempt cache prevents an earlier cell or local rebuild // from producing `504 Outdated Optimize Dep` during browser bootstrap. @@ -569,8 +579,6 @@ async function runAttempt(input: { delete childEnv.DATABASE_MIGRATION_URL; const playwrightArgs = [ - "exec", - "playwright", "test", "--config", "tests/runner-e2e/playwright.config.ts", @@ -590,7 +598,7 @@ async function runAttempt(input: { ) + 5 * 60_000; const processResult = await runProcess( - playwrightArgs, + runnerE2EPlaywrightInvocation(repositoryRoot, playwrightArgs, Boolean(installedCli)), childEnv, watchdog, path.join(privateDir, "playwright.log"), @@ -600,6 +608,18 @@ async function runAttempt(input: { options.ui || options.debug, ); processCleanupFailed = processResult.processCleanupError !== null; + if (options.installedStartupOnly) { + const proofDir = path.join(resultsRoot, campaignId, "installed-startup-only"); + await mkdir(proofDir, { recursive: true }); + // This is private setup evidence, never a successful provider case/result. + await cp(privateDir, path.join(proofDir, "private"), { recursive: true }); + const probe = JSON.parse(await readFile(path.join(privateDir, "installed-startup-only.json"), "utf8")); + if (processResult.exitCode !== 0 || processResult.timedOut || processResult.spawnError || processCleanupFailed || probe.status !== "health_ui_zero_company_passed" || probe.providerCalls !== 0 || probe.qualified !== false) throw new Error("Installed startup-only actual launch chain failed"); + await assertEmbeddedDatabaseIsolation(configPath, temporaryRoot); + startupReceiptPath = path.join(proofDir, "receipt.json"); + await writeFile(startupReceiptPath, `${JSON.stringify({ status: "installed_launch_chain_pending_scratch_cleanup", qualified: false, providerCalls: 0, credentialsLoaded: false, installedCli, processResult, probe }, null, 2)}\n`, { mode: 0o600 }); + return []; + } const processFailure = processResult.spawnError ? `Playwright failed to start: ${processResult.spawnError}` : processResult.timedOut @@ -844,6 +864,12 @@ async function runAttempt(input: { `Refusing to remove unexpected temporary path ${temporaryRoot}`, ); } + if (startupReceiptPath) { + const receipt = JSON.parse(await readFile(startupReceiptPath, "utf8")); + receipt.status = cleanupError ? "installed_launch_chain_cleanup_failed" : "installed_launch_chain_passed"; + receipt.temporaryRootRemoved = !cleanupError; + await writeFile(startupReceiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 }); + } if (cleanupError) { const message = `Temporary runner E2E state cleanup failed at ${temporaryRoot}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`; for (const publishedResult of publishedResults) { @@ -868,6 +894,7 @@ async function runAttempt(input: { // Cleanup failure is a failed cell, but must not suppress later cells in // the same campaign. The result above carries the terminal failure. console.error(message); + if (options.installedStartupOnly) throw new Error(message); } } } @@ -942,6 +969,14 @@ async function main() { throw error; } const executions = selectRunnerExecutions(options, runnerMatrix); + if (options.installedStartupOnly) { + assertInstalledStartupOnly(process.env, executions, options); + assertRunnerE2EPrerequisites(executions); + const campaignId = cleanId(process.env.PAPERCLIP_E2E_CAMPAIGN_ID ?? `installed-startup-${Date.now()}`); + await runAttempt({ executions, attempt: 1, campaignId, options }); + console.log(`PASS installed launch chain (no provider qualification) -> ${path.join(resultsRoot, campaignId, "installed-startup-only")}`); + return; + } if (options.list) { printList(executions); return; diff --git a/tests/runner-e2e/live-fixtures.ts b/tests/runner-e2e/live-fixtures.ts index 26512d6079..07a8397cba 100644 --- a/tests/runner-e2e/live-fixtures.ts +++ b/tests/runner-e2e/live-fixtures.ts @@ -1,3 +1,4 @@ +import { verifyInstalledDaytonaPlugin } from "./installed-daytona-plugin.js"; import { NATIVE_COMPLETION_BUDGET_CENTS } from "./native-completion-defaults.js"; import path from "node:path"; import { installedReleaseDaytonaPlugin } from "./installed-release.js"; @@ -115,10 +116,9 @@ export async function setupLiveFixtures(input: { registry.register({ id: "sandbox-provider", async setup() { + const installed = await verifyInstalledDaytonaPlugin(process.env, [execution]); return api.post("/api/plugins/install", { - packageName: process.env.PAPERCLIP_RUNNER_E2E_INSTALLED_CLI - ? installedReleaseDaytonaPlugin(process.env.PAPERCLIP_RUNNER_E2E_INSTALLED_CLI, process.env.PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN, process.env.PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_VERSION) - : path.resolve( + packageName: installed?.packageRoot ?? path.resolve( import.meta.dirname, "../../packages/plugins/sandbox-providers/daytona", ), diff --git a/tests/runner-e2e/native-active-stop-evidence.ts b/tests/runner-e2e/native-active-stop-evidence.ts index 81b928b8f9..1b7b0d22ff 100644 --- a/tests/runner-e2e/native-active-stop-evidence.ts +++ b/tests/runner-e2e/native-active-stop-evidence.ts @@ -2,13 +2,14 @@ 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 { assertCopilotRemoteRetirement, copilotRemoteDeniedSample, type CopilotRemoteSnapshot } from "./copilot-protection-evidence.js"; +import { readCopilotToolEvidence } from "./copilot-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 type ActiveStopProvider = "cursor" | "copilot"; 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"; @@ -61,7 +62,7 @@ const closures = new Set(["runtime_request.resolved", "runtime_request.cancelled 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 notices = scope.provider === "cursor" ? readCursorToolEvidence(events, scope.runId) : readCopilotToolEvidence(events, scope.runId); const requests = notices.filter(n => n.stage === "permission_requested"); fail(requests.length === 1, "one native permission required"); const notice = requests[0]!; @@ -119,7 +120,7 @@ export function observeActiveStopPending(input: { events: readonly unknown[]; ru 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(!(scope.provider === "cursor" ? readCursorToolEvidence(input.events, scope.runId) : readCopilotToolEvidence(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, @@ -180,7 +181,7 @@ export function readActiveStopSettlement(input: { events: readonly unknown[]; ru export interface ActiveStopRemoteObservation { phase: "before-request" | "pending" | "owned-process-retirement"; source: "live-snapshot" | "retirement-seal"; - snapshot: NativeRemoteSnapshot; + snapshot: CopilotRemoteSnapshot; } /** A per-turn Daytona observer seals itself when the owned tree retires. The @@ -192,10 +193,10 @@ export function readActiveStopRemoteRetirement(input: { 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]; + const [baseline, pending, terminal] = observations.map(o => o.snapshot) as [CopilotRemoteSnapshot, CopilotRemoteSnapshot, CopilotRemoteSnapshot]; 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(!copilotRemoteDeniedSample(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"); @@ -203,8 +204,8 @@ export function readActiveStopRemoteRetirement(input: { && 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); + assertCopilotRemoteRetirement(terminal, baseline); + assertCopilotRemoteRetirement(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"); diff --git a/tests/runner-e2e/native-active-stop-flow.ts b/tests/runner-e2e/native-active-stop-flow.ts index 98475efb6a..b3290af7ab 100644 --- a/tests/runner-e2e/native-active-stop-flow.ts +++ b/tests/runner-e2e/native-active-stop-flow.ts @@ -4,8 +4,8 @@ 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 { createDeniedTargetFixture, exists, observeRunProcesses } from "./copilot-local-fixtures.js"; +import { assertCopilotRemoteRetirement, copilotRemoteDeniedSample, prepareCopilotRemoteAction, type CopilotRemoteBootstrap, type CopilotRemoteFixture, type CopilotRemoteSnapshot } from "./copilot-protection-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"; @@ -43,7 +43,7 @@ export async function stopAtPendingPermission(input: { } export async function runNativeActiveStopFlow(input: { page: Page; api: RunnerApi; fixtures: LiveFixtureValues; execution: MatrixExecution; nonce: string; workspacePath: string; deadlineAt: number; - remoteBootstrap?: NativeRemoteBootstrap; + remoteBootstrap?: CopilotRemoteBootstrap; registerCleanupAssertion(callback: () => Promise): void; registerBeforeEnvironmentTeardownAssertion(callback: () => Promise): void; observe(issue: Row, runs: Row[]): void; @@ -51,7 +51,7 @@ export async function runNativeActiveStopFlow(input: { 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"); + if ((provider !== "cursor" && provider !== "copilot") || 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[] = []; @@ -65,15 +65,12 @@ export async function runNativeActiveStopFlow(input: { 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 fixture: CopilotRemoteFixture | undefined, baseline: CopilotRemoteSnapshot | undefined, sealed: CopilotRemoteSnapshot | undefined; let completed: Awaited> | undefined; const observeProcesses = () => { - // Remote ownership comes from the sandbox boot/start-tick journal. The - // API PID transitions from the controller command to the remote runner. - if (!observer) return; 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; } - processes = observer.sample(authority); + if (observer) processes = observer.sample(authority); }; const load = async (): Promise => { if (issue.id) issue = await api.get(`/api/issues/${issue.id}`); @@ -90,7 +87,7 @@ export async function runNativeActiveStopFlow(input: { 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; + value = !copilotRemoteDeniedSample(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"); @@ -103,7 +100,7 @@ export async function runNativeActiveStopFlow(input: { 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; + sealed ??= await fixture.finish(); assertCopilotRemoteRetirement(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 }); } @@ -136,13 +133,13 @@ export async function runNativeActiveStopFlow(input: { 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"); - const createdTask = 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, requireExplicitTitle: true }); - 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.id === createdTask.issueId), accept: Boolean }))!; + 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() }); + const prepared = await prepareCopilotRemoteAction({ 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; @@ -168,7 +165,7 @@ export async function runNativeActiveStopFlow(input: { 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; + sealed = await fixture!.finish(); assertCopilotRemoteRetirement(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"); diff --git a/tests/runner-e2e/native-active-stop.test.ts b/tests/runner-e2e/native-active-stop.test.ts index 63eab1a5a6..90520c321c 100644 --- a/tests/runner-e2e/native-active-stop.test.ts +++ b/tests/runner-e2e/native-active-stop.test.ts @@ -5,6 +5,7 @@ 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 { createCopilotToolEvidence } from "../../packages/paperclip-runner/src/drivers/acpx/copilot-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"; @@ -13,7 +14,7 @@ import { mapTerminalTurn } from "../../packages/paperclip-runner/src/drivers/cod 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") { +function fixture(provider: ActiveStopProvider = "copilot") { 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", @@ -81,7 +82,7 @@ function withProjectedArrivalOrder(provider: ActiveStopProvider, order: typeof a } f.events.length = 0; const append = (eventType: string, value: Row) => f.events.push(f.row(f.events.length + 1, eventType, value)); - const projector = (createCursorToolEvidence)({ + const projector = (provider === "cursor" ? createCursorToolEvidence : createCopilotToolEvidence)({ sessionId: "native-session", turnId: "turn", workingDirectory: "/fixture", active: () => true, emit: (event: CanonicalProviderEvent) => { append(event.eventType, event.payload); }, }); @@ -124,10 +125,11 @@ function withSessionPrefix(provider: ActiveStopProvider = "cursor") { } describe("definitely active native permission Stop", () => { - describe.each(["cursor"] as const)("%s pre-Stop arrival orders", provider => { + describe.each(["cursor", "copilot"] 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(f.nativeStageOrder()).toEqual(provider === "copilot" && order === "permission-first" + ? ["permission_requested", "tool"] : ["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); @@ -187,7 +189,7 @@ describe("definitely active native permission Stop", () => { }); 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 => { + it.each(["cursor", "copilot"] 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 }); }); @@ -211,7 +213,7 @@ describe("definitely active native permission Stop", () => { 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 => { + it.each(["cursor", "copilot"] 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({ @@ -327,6 +329,7 @@ describe("definitely active native permission Stop", () => { describe("active Stop flow wiring", () => { it.each([ ["cursor", "tool-first"], ["cursor", "permission-first"], + ["copilot", "tool-first"], ["copilot", "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); }); @@ -367,11 +370,11 @@ describe("active Stop flow wiring", () => { }); describe("explicit active Stop discovery", () => { - it("adds exactly two versioned cells without enabling them in --all", () => { + it("adds exactly four 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).toHaveLength(4); expect(suite.manualOnly).toBe(true); + expect(cells.map(e => `${e.profile.qualificationCandidate}/${e.environment.id}`).sort()).toEqual(["copilot/daytona", "copilot/local", "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 } })); diff --git a/tests/runner-e2e/native-bootstrap-read-proof.test.ts b/tests/runner-e2e/native-bootstrap-read-proof.test.ts index ac87aa1bfd..f13e4ce524 100644 --- a/tests/runner-e2e/native-bootstrap-read-proof.test.ts +++ b/tests/runner-e2e/native-bootstrap-read-proof.test.ts @@ -81,10 +81,12 @@ it("accepts omitted terminal path/kind only with the retained single-path origin } }); -it.each(["cursor"] as const)("correlates actual %s passive notices through the public reader and canonical omitted-field updates", async provider => { +it.each(["cursor", "copilot"] 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 { createCopilotToolEvidence } = await import("../../packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.js"); const { readCursorToolEvidence } = await import("./cursor-native-evidence.js"); - const create = createCursorToolEvidence; + const { readCopilotToolEvidence } = await import("./copilot-evidence.js"); + const create = provider === "cursor" ? createCursorToolEvidence : createCopilotToolEvidence; 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, @@ -96,7 +98,7 @@ it.each(["cursor"] as const)("correlates actual %s passive notices through the p 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 readNotices = provider === "cursor" ? readCursorToolEvidence(rows, "run") : readCopilotToolEvidence(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] }); diff --git a/tests/runner-e2e/pi-bootstrap-permission.test.ts b/tests/runner-e2e/pi-bootstrap-permission.test.ts new file mode 100644 index 0000000000..7413da01b3 --- /dev/null +++ b/tests/runner-e2e/pi-bootstrap-permission.test.ts @@ -0,0 +1,76 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { canonicalJson } from "../../packages/shared/src/portability-hash.js"; +import { approvePiBootstrapRead, withoutApprovedPiBootstrapRequests, type PiBootstrapApproval } from "./pi-bootstrap-permission.js"; +import { observePiControlPending, readPiStopSettlement, readPiSteeringSettlement } from "./pi-controls-evidence.js"; +import { piCancellationId, piControlCaller, piControlFixture } from "./pi-controls-test-fixture.js"; +const actionFile = `.paperclip-eval-action-${"a".repeat(36)}.txt`; +const digest = (value: unknown) => `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`; +function fixture() { + const f = piControlFixture(), write = structuredClone(f.events); + f.events.length = 0; + const read = { schema: "paperclip.tool.execution.v1", transport: "builtin", executionId: "setup-tool", name: "read", operation: "read", target: actionFile, status: "running" }; + const request = { ...f.request, requestId: "setup-request", itemId: "setup-item", details: { toolCallId: "setup-tool" }, choices: [{ key: "accept", label: "Allow once" }, { key: "decline", label: "Deny" }] }; + f.append("tool.execution.started", read); f.append("runtime_request.created", { request }); + const finish = () => { + f.append("runtime_request.resolved", { requestId: request.requestId, turnId: "turn", action: "accept" }); + f.append("tool.execution.completed", { ...read, status: "completed" }); + for (const row of write) f.append(row.eventType, row.payload.prpEvent.payload); + }; + const proof = (): PiBootstrapApproval => ({ schema: "paperclip.e2e.pi-bootstrap-read-approval.v1", companyId: f.scope.companyId, issueId: f.scope.issueId, runId: f.scope.runId, actionFile, publishedSha256: `sha256:${"b".repeat(64)}`, + requestId: "setup-request", toolCallId: "setup-tool", turnId: "turn", normalizedSessionId: "session", sourceInstanceId: "source", + createdRowSha256: digest(f.events[1]), resolvedRowSha256: digest(f.events[2]), completedRowSha256: digest(f.events[3]) }); + return { ...f, read, finish, proof }; +} + +describe("Pi operator setup read", () => { + it("approves only the published owned file through the exact public request", async () => { + const f = fixture(), saved = new Map(); let posts = 0; + const result = await approvePiBootstrapRead({ + api: { post: async (path: string, data: unknown) => { expect(path).toBe("/api/heartbeat-runs/run/runtime-requests/setup-request/resolve"); expect(data).toEqual({ turnId: "turn", requestKind: "permission_approval", resolution: { action: "accept" } }); posts++; f.finish(); } } as any, + fixture: { actionFile, binding: { companyId: "company", runId: "run" }, snapshot: async () => ({ complete: true, setup: { path: actionFile, published: true, sha256: `sha256:${"b".repeat(64)}` } }) } as any, + companyId: "company", issueId: "issue", runId: "run", deadlineAt: Date.now() + 2000, load: async () => f.state(), evidence: async (name, value) => { saved.set(name, value); }, + }); + expect(posts).toBe(1); expect(result).toEqual(f.proof()); expect(saved.has("pi-bootstrap-read-before-approval.json")).toBe(true); + const pending = observePiControlPending({ ...f.state(), scope: f.scope, bootstrapApproval: result }); + expect(pending.requestId).toBe("request"); expect(pending.bootstrapApproval).toEqual(result); + }); + + it.each(["unpublished", "foreign-binding", "other-file", "shell"])("does not approve %s setup", async failure => { + const f = fixture(); let posts = 0; + if (failure === "other-file") f.events[0]!.payload.prpEvent.payload.target = "private.txt"; + if (failure === "shell") { f.events[0]!.payload.prpEvent.payload.name = "bash"; f.events[0]!.payload.prpEvent.payload.operation = "execute"; } + await expect(approvePiBootstrapRead({ api: { post: async () => { posts++; } } as any, + fixture: { actionFile, binding: { companyId: failure === "foreign-binding" ? "other" : "company", runId: "run" }, snapshot: async () => ({ complete: true, setup: { path: actionFile, published: failure !== "unpublished", sha256: `sha256:${"b".repeat(64)}` } }) } as any, + companyId: "company", issueId: "issue", runId: "run", deadlineAt: Date.now() + 2000, load: async () => f.state(), evidence: async () => {}, + })).rejects.toThrow("Pi bootstrap"); expect(posts).toBe(0); + }); + + it.each(["stop", "steering"])("retains the original %s assertions after a proven setup read", mode => { + const f = fixture(); f.finish(); const proof = f.proof(); + expect(() => observePiControlPending({ ...f.state(), scope: f.scope })).toThrow("one native permission"); + const pending = observePiControlPending({ ...f.state(), scope: f.scope, bootstrapApproval: proof }); + if (mode === "stop") { f.cancel(); expect(readPiStopSettlement({ ...f.state(), pending, caller: piControlCaller, cancellationRequestId: piCancellationId, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() }).normalCompletionAccepted).toBe(false); } + else { f.steer(); + // The wrapper's setup finish is separate from the original write finish. + f.append("runtime_request.resolved", { requestId: "request", turnId: "turn", action: "decline" }); + f.append("tool.execution.completed", { ...f.tool, status: "failed", output: "Pi operation was denied or cancelled" }); + f.append("turn.completed", { status: "completed", error: null }); f.run.status = "succeeded"; f.issue.status = "done"; + f.append("run.result.accepted", { result: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: f.marker } }, { sourceKind: "control_plane", sourceInstanceId: "source:control", sourceSeq: 1, sourceEventId: "source:control:run:1" }); + f.append("run.terminal", { schema: "paperclip.prp.terminal.v1", runTerminalState: "succeeded", turnTerminalState: "completed", reportedWorkDisposition: "done" }, { sourceKind: "control_plane", sourceInstanceId: "source:control", sourceSeq: 2, sourceEventId: "source:control:run:2" }); + expect(readPiSteeringSettlement({ ...f.state(), pending, commentId: f.commentId, queueId: f.queueId, marker: f.marker, finalMessage: f.marker }).marker).toBe(f.marker); + } + }); + + it.each(["changed-receipt", "wrong-path", "failed-read", "extra-permission", "reordered", "allow-always"])("rejects %s before excluding setup records", failure => { + const f = fixture(); f.finish(); const proof = f.proof(); + if (failure === "changed-receipt") proof.resolvedRowSha256 = `sha256:${"0".repeat(64)}`; + if (failure === "wrong-path") f.events[0]!.payload.prpEvent.payload.target = "other.txt"; + if (failure === "failed-read") f.events[3]!.payload.prpEvent.payload.status = "failed"; + if (failure === "extra-permission") f.append("runtime_request.created", { request: { ...f.request, requestId: "extra" } }); + if (failure === "reordered") f.events[3]!.seq = 1; + if (failure === "allow-always") f.events[2]!.payload.prpEvent.payload.action = "accept_always"; + expect(() => observePiControlPending({ ...f.state(), scope: f.scope, bootstrapApproval: proof })).toThrow(); + }); + it("does not discard any native lifecycle row", () => { const f = fixture(); f.finish(); const rows = withoutApprovedPiBootstrapRequests(f.events, f.proof()); expect(rows).toHaveLength(f.events.length - 2); expect(rows.filter((row: any) => row.eventType.startsWith("tool.execution."))).toHaveLength(3); }); +}); diff --git a/tests/runner-e2e/pi-bootstrap-permission.ts b/tests/runner-e2e/pi-bootstrap-permission.ts new file mode 100644 index 0000000000..ea7054bfc0 --- /dev/null +++ b/tests/runner-e2e/pi-bootstrap-permission.ts @@ -0,0 +1,134 @@ +import { createHash } from "node:crypto"; +import { canonicalJson } from "../../packages/shared/src/portability-hash.js"; +import { pollUntil, type RunnerApi } from "./api.js"; +import { bootstrapReadExecutionId } from "./native-bootstrap-read-proof.js"; +import { isValidNativePrpEnvelope } from "./native-event-envelope.js"; +import { piPermissionRequests } from "./pi-native-evidence.js"; +import type { RemoteNativeFixture, RemoteNativeSnapshot } from "./remote-native-fixtures.js"; + +type Row = Record; +const hash = (row: unknown) => `sha256:${createHash("sha256").update(canonicalJson(row)).digest("hex")}`; +const requireProof = (ok: unknown) => { if (!ok) throw new Error("Pi bootstrap: exact published read approval is unproven"); }; +export interface PiBootstrapApproval { + schema: "paperclip.e2e.pi-bootstrap-read-approval.v1"; + companyId: string; issueId: string; runId: string; actionFile: string; publishedSha256: string; + requestId: string; toolCallId: string; turnId: string; normalizedSessionId: string; sourceInstanceId: string; + createdRowSha256: string; resolvedRowSha256: string; completedRowSha256: string; +} +export interface PiBootstrapState { run: Row; issue: Row; events: readonly unknown[] } + +function readLifecycle(events: readonly unknown[], input: Pick) { + requireProof(events.length > 0 && events.length <= 20_000 && /^\.paperclip-eval-action-[a-f0-9]{36}\.txt$/u.test(input.actionFile)); + const seqs = new Set(), sourceIds = new Set(), lastSource = new Map(); + for (const row of events as readonly Row[]) { + const e = row.payload?.prpEvent; if (e === undefined) continue; + requireProof(e && isValidNativePrpEnvelope(e, row.protocolSchemaVersion) && row.companyId === input.companyId + && row.runId === input.runId && e.runId === input.runId && e.eventType === row.eventType + && ["runner", "control_plane"].includes(e.sourceKind) && typeof e.sourceInstanceId === "string" && e.sourceInstanceId.length > 0 + && Number.isSafeInteger(row.seq) && row.seq > 0 && !seqs.has(row.seq) + && Number.isSafeInteger(e.sourceSeq) && e.sourceSeq > (lastSource.get(e.sourceInstanceId) ?? 0) + && e.sourceEventId === `${e.sourceInstanceId}:${input.runId}:${e.sourceSeq}` && !sourceIds.has(e.sourceEventId) + && Number.isFinite(Date.parse(e.emittedAt))); + seqs.add(row.seq); sourceIds.add(e.sourceEventId); lastSource.set(e.sourceInstanceId, e.sourceSeq); + } + const created = piPermissionRequests(events, input.runId).filter(x => x.request.requestId === input.requestId); + requireProof(created.length === 1); const card = created[0]!; + requireProof(isValidNativePrpEnvelope(card.event, card.row.protocolSchemaVersion) && card.row.companyId === input.companyId && card.request.requestKind === "permission_approval" + && typeof card.event.turnId === "string" && card.event.turnId.length > 0 + && typeof card.event.normalizedSessionId === "string" && card.event.normalizedSessionId.length > 0 + && card.request.choices.filter((choice: Row) => choice.key === "accept").length === 1); + const executionId = bootstrapReadExecutionId(card.request.details.toolCallId); + const rows = (events as readonly Row[]).filter(row => row.payload?.prpEvent?.payload?.executionId === executionId); + requireProof(rows.length > 0 && rows.length <= 2048); + let known = false, lastSeq = 0; + for (const row of rows) { + const event = row.payload.prpEvent, p = event.payload; + requireProof(isValidNativePrpEnvelope(event, row.protocolSchemaVersion) && row.companyId === input.companyId && row.runId === input.runId && event.runId === input.runId + && row.protocolSchemaVersion === 1 && event.schema === "paperclip.prp.event.v1" && event.schemaVersion === 1 + && event.sourceKind === "runner" && event.turnId === card.event.turnId && event.normalizedSessionId === card.event.normalizedSessionId + && event.sourceInstanceId === card.event.sourceInstanceId && event.eventType === row.eventType + && Number.isSafeInteger(row.seq) && row.seq > lastSeq + && p.schema === "paperclip.tool.execution.v1" && p.transport === "builtin" && p.name === "read" && p.operation === "read" + && ["tool.execution.started", "tool.execution.progressed", "tool.execution.completed"].includes(row.eventType) + && (p.target === input.actionFile || (!known && p.target === null && row.eventType !== "tool.execution.completed"))); + known ||= p.target === input.actionFile; lastSeq = row.seq; + } + requireProof(known && rows[0]!.eventType === "tool.execution.started" + && rows.filter(row => row.eventType === "tool.execution.started").length === 1 + && rows.filter(row => row.eventType === "tool.execution.completed").length <= 1 + && rows.every((row, index) => row.eventType === "tool.execution.completed" + ? index === rows.length - 1 && row.payload.prpEvent.payload.status === "completed" + : row.payload.prpEvent.payload.status === "running")); + return { card, rows, executionId }; +} + +/** Remove only the two hash-bound setup permission records. All native read + * lifecycle rows and every tested write/permission remain in the oracle. */ +export function withoutApprovedPiBootstrapRequests(events: readonly unknown[], proof?: PiBootstrapApproval): unknown[] { + if (!proof) return [...events]; + requireProof(proof.schema === "paperclip.e2e.pi-bootstrap-read-approval.v1" && /^sha256:[a-f0-9]{64}$/u.test(proof.publishedSha256)); + const { card, rows } = readLifecycle(events, proof); + requireProof(card.request.details.toolCallId === proof.toolCallId && card.event.turnId === proof.turnId + && card.event.normalizedSessionId === proof.normalizedSessionId && card.event.sourceInstanceId === proof.sourceInstanceId + && hash(card.row) === proof.createdRowSha256); + const closures = (events as readonly Row[]).filter(row => ["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"].includes(row.eventType) + && row.payload?.prpEvent?.payload?.requestId === proof.requestId); + requireProof(closures.length === 1); const closed = closures[0]!, e = closed.payload.prpEvent; + requireProof(isValidNativePrpEnvelope(e, closed.protocolSchemaVersion) && closed.companyId === proof.companyId && closed.runId === proof.runId && closed.protocolSchemaVersion === 1 + && e.schema === "paperclip.prp.event.v1" && e.schemaVersion === 1 && e.sourceKind === "runner" && e.runId === proof.runId + && e.eventType === "runtime_request.resolved" && closed.eventType === e.eventType && e.sourceInstanceId === proof.sourceInstanceId + && e.turnId === proof.turnId && e.normalizedSessionId === proof.normalizedSessionId && e.payload.turnId === proof.turnId + && e.payload.action === "accept" && closed.seq > card.row.seq && hash(closed) === proof.resolvedRowSha256); + const completed = rows.at(-1)!; + requireProof(completed.eventType === "tool.execution.completed" && completed.seq > closed.seq && hash(completed) === proof.completedRowSha256); + const tested = piPermissionRequests(events, proof.runId).filter(x => x.request.requestId !== proof.requestId); + requireProof(tested.every(x => x.row.seq > completed.seq && x.event.turnId === proof.turnId + && x.event.normalizedSessionId === proof.normalizedSessionId && x.event.sourceInstanceId === proof.sourceInstanceId)); + return events.filter(row => row !== card.row && row !== closed); +} + +/** The operator may approve one setup read after publication and observer + * arming. Production permission policy and the tested write are untouched. */ +export async function approvePiBootstrapRead(input: { + api: RunnerApi; fixture: { actionFile: string; binding: Pick; snapshot(label: string): Promise> }; companyId: string; issueId: string; runId: string; + deadlineAt: number; load(): Promise; evidence(name: string, value: unknown): Promise; +}): Promise { + const actionFile = input.fixture.actionFile; + requireProof(/^\.paperclip-eval-action-[a-f0-9]{36}\.txt$/u.test(actionFile) + && input.fixture.binding.companyId === input.companyId && input.fixture.binding.runId === input.runId); + const pending = await pollUntil({ label: "Pi published bootstrap read", deadlineAt: input.deadlineAt, intervalMs: 100, + load: input.load, reject: state => ["failed", "timed_out", "cancelled", "succeeded"].includes(state.run.status) ? "Pi bootstrap run settled" : undefined, + accept: state => piPermissionRequests(state.events, input.runId).length > 0 }); + requireProof(pending.run.status === "running" && pending.run.runtimeMode === "native" && pending.issue.status === "in_progress" && pending.run.id === input.runId && pending.run.companyId === input.companyId && pending.run.nativeIssueId === input.issueId + && pending.issue.id === input.issueId && pending.issue.companyId === input.companyId); + const requests = piPermissionRequests(pending.events, input.runId); requireProof(requests.length === 1); + const card = requests[0]!, executionId = bootstrapReadExecutionId(card.request.details.toolCallId); + const native = (pending.events as readonly Row[]).filter(row => row.payload?.prpEvent?.payload?.executionId === executionId); + // A read permitted without delegation can already have led to the tested + // write. There is no setup permission to remove in that existing path. + if (native.some(row => row.payload.prpEvent.payload.operation === "edit")) return undefined; + const checked = readLifecycle(pending.events, { companyId: input.companyId, runId: input.runId, actionFile, requestId: card.request.requestId }); + requireProof(!checked.rows.some(row => row.eventType === "tool.execution.completed")); + const snapshot = await input.fixture.snapshot("bootstrap-read-published"); + requireProof(snapshot.complete && snapshot.setup.path === actionFile && snapshot.setup.published + && /^sha256:[a-f0-9]{64}$/u.test(snapshot.setup.sha256 ?? "")); + await input.evidence("pi-bootstrap-read-before-approval.json", { binding: input.fixture.binding, snapshot, request: card.row, native: checked.rows }); + await input.api.post(`/api/heartbeat-runs/${input.runId}/runtime-requests/${encodeURIComponent(card.request.requestId)}/resolve`, + { turnId: card.event.turnId, requestKind: "permission_approval", resolution: { action: "accept" } }); + const finished = await pollUntil({ label: "Pi exact bootstrap read completion", deadlineAt: input.deadlineAt, intervalMs: 100, load: input.load, + reject: state => ["failed", "timed_out", "cancelled", "succeeded"].includes(state.run.status) ? "Pi bootstrap run settled" : undefined, + accept: state => (state.events as readonly Row[]).some(row => row.eventType === "tool.execution.completed" + && row.payload?.prpEvent?.payload?.executionId === executionId) }); + const closure = (finished.events as readonly Row[]).find(row => row.eventType === "runtime_request.resolved" && row.payload?.prpEvent?.payload?.requestId === card.request.requestId); + const completed = (finished.events as readonly Row[]).find(row => row.eventType === "tool.execution.completed" && row.payload?.prpEvent?.payload?.executionId === executionId); + requireProof(closure && completed); + const after = await input.fixture.snapshot("bootstrap-read-completed"); + requireProof(after.complete && after.setup.path === actionFile && after.setup.published && after.setup.sha256 === snapshot.setup.sha256); + await input.evidence("pi-bootstrap-read-completed.json", { binding: input.fixture.binding, snapshot: after }); + const proof: PiBootstrapApproval = { schema: "paperclip.e2e.pi-bootstrap-read-approval.v1", companyId: input.companyId, issueId: input.issueId, runId: input.runId, + actionFile, publishedSha256: snapshot.setup.sha256!, requestId: card.request.requestId, toolCallId: card.request.details.toolCallId, + turnId: card.event.turnId, normalizedSessionId: card.event.normalizedSessionId, sourceInstanceId: card.event.sourceInstanceId, + createdRowSha256: hash(card.row), resolvedRowSha256: hash(closure), completedRowSha256: hash(completed) }; + withoutApprovedPiBootstrapRequests(finished.events, proof); + await input.evidence("pi-bootstrap-read-approval.json", proof); return proof; +} diff --git a/tests/runner-e2e/pi-controls-evidence.test.ts b/tests/runner-e2e/pi-controls-evidence.test.ts index 10b1174bfe..b28f3b3091 100644 --- a/tests/runner-e2e/pi-controls-evidence.test.ts +++ b/tests/runner-e2e/pi-controls-evidence.test.ts @@ -16,8 +16,35 @@ function cancelled() { const binding = { pending, caller: piControlCaller, cancellationRequestId: piCancellationId, dispatchMonotonicNs: String(BigInt(pending.observedMonotonicNs) + 1n) }; return { f, binding, read: () => readPiStopSettlement({ ...f.state(), ...binding }) }; } +function streamedWrite() { + const f = piControlFixture(); + f.events.pop(); + event(f.events[0]!).payload = { ...f.tool, target: null, inputUpdated: false }; + f.append("tool.execution.progressed", { ...f.tool, target: null, inputUpdated: true }); + f.append("tool.execution.progressed", { ...f.tool, inputUpdated: true }); + f.append("runtime_request.created", { request: f.request }); + return f; +} describe("Pi pending control identity", () => { it("accepts Pi native permission/start without invented provider notices", () => expect(piControlFixture().pending()).toMatchObject({ toolCallId: "pi-tool", executionId: "pi-tool", turnId: "turn" })); + it("accepts streamed arguments only after the same execution proves its exact path", () => { + const f = streamedWrite(); + expect(f.pending()).toMatchObject({ startedSourceSeq: 1, requestSourceSeq: 4 }); + }); + it.each([ + ["no complete target", (f: ReturnType) => { event(f.events[2]!).payload.target = null; }], + ["conflicting partial target", f => { event(f.events[1]!).payload.target = "other.txt"; }], + ["target lost after admission", f => { f.append("tool.execution.progressed", { ...f.tool, target: null }); }], + ["different execution supplied target", f => { event(f.events[2]!).payload.executionId = "other"; }], + ["terminal before target", f => { f.events[1]!.eventType = event(f.events[1]!).eventType = "tool.execution.completed"; event(f.events[1]!).payload.status = "failed"; }], + ] satisfies Array<[string, (f: ReturnType) => void]>)("rejects streamed write with %s", (_name, mutate) => { + const f = streamedWrite(); mutate(f); expect(() => f.pending()).toThrow(); + }); + it("retains the partial start hash through actual callback cancellation", () => { + const f = streamedWrite(), pending = f.pending(); f.cancel(); + expect(readPiStopSettlement({ ...f.state(), pending, caller: piControlCaller, cancellationRequestId: piCancellationId, + dispatchMonotonicNs: String(BigInt(pending.observedMonotonicNs) + 1n) })).toMatchObject({ normalCompletionAccepted: false }); + }); it("admits permission-first ACP only after both canonical boundaries exist", () => { const f = piControlFixture(); f.events.reverse(); f.events.forEach((r, i) => { r.seq = event(r).sourceSeq = i + 1; event(r).sourceEventId = `source:run:${i + 1}`; }); @@ -84,6 +111,35 @@ function steered() { return { f, binding, ack, read: () => readPiSteeringSettlement({ ...f.state(), ...binding }) }; } describe("Pi same-turn steering", () => { + function withControlSettlement() { + return steered(); + } + it("keeps exact runner proof when the control plane records result and terminal events", () => { + expect(withControlSettlement().read()).toMatchObject({ nativeFollowUpTested: false }); + }); + it.each([ + ["foreign control producer", (s: ReturnType) => { event(s.f.events.at(-1)!).sourceInstanceId = "foreign:control"; event(s.f.events.at(-1)!).sourceEventId = "foreign:control:run:2"; }], + ["foreign control turn", s => { event(s.f.events.at(-1)!).turnId = "foreign"; }], + ["foreign control session", s => { event(s.f.events.at(-1)!).normalizedSessionId = "foreign"; }], + ["unknown control event", s => { s.f.events.at(-1)!.eventType = event(s.f.events.at(-1)!).eventType = "runtime_request.resolved"; }], + ["reordered control sequence", s => { event(s.f.events.at(-1)!).sourceSeq = 1; event(s.f.events.at(-1)!).sourceEventId = "source:control:run:1"; }], + ["duplicate control terminal", s => { s.f.append("run.terminal", { schema: "paperclip.prp.terminal.v1" }, { sourceKind: "control_plane", sourceInstanceId: "source:control", sourceSeq: 3, sourceEventId: "source:control:run:3" }); }], + ["unbound control result", s => { event(s.f.events.at(-2)!).payload.result.schema = "foreign"; }], + ["both control records missing", s => { s.f.events.splice(-2); }], + ["accepted result missing", s => { s.f.events.splice(-2, 1); }], + ["control terminal missing", s => { s.f.events.pop(); }], + ["wrong control summary", s => { event(s.f.events.at(-2)!).payload.result.summary = "Finished"; }], + ["unfinished control result", s => { event(s.f.events.at(-2)!).payload.result.reportedWorkDisposition = "in_progress"; }], + ["failed control terminal", s => { event(s.f.events.at(-1)!).payload.runTerminalState = "failed"; }], + ["interrupted control turn", s => { event(s.f.events.at(-1)!).payload.turnTerminalState = "interrupted"; }], + ["unfinished control terminal", s => { event(s.f.events.at(-1)!).payload.reportedWorkDisposition = "in_progress"; }], + ["control terminal before runner completion", s => { + for (const row of s.f.events.slice(3, 6)) row.seq += 2; + s.f.events.at(-2)!.seq = 4; s.f.events.at(-1)!.seq = 5; + }], + ] satisfies Array<[string, (s: ReturnType) => void]>)("rejects %s", (_name, mutate) => { + const s = withControlSettlement(); mutate(s); expect(s.read).toThrow(); + }); it("calibrates against the actual Product ACP facade producer, not Rust's raw transport echo", async () => { const f = piControlFixture(), pending = f.pending(), calls: Row[] = []; // Only the transport and event sink are doubles. Run the actual public @@ -124,23 +180,30 @@ describe("Pi controls catalog admission", () => { expect(cells).toHaveLength(4); expect(cells.every(c => c.profile.qualificationCandidate === "pi" && c.task.expectedRunCount === 1)).toBe(true); expect(cells.map(c => `${c.environment.id}/${c.task.id}`).sort()).toEqual(["daytona/pending-permission-stop", "daytona/same-turn-steering", "local/pending-permission-stop", "local/same-turn-steering"]); expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(c => c.suite.id === "pi-controls")).toBe(false); - expect(validateRunnerCatalog()).toHaveLength(469); - for (const cell of cells) expect(JSON.parse(buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION!)).toEqual([{ agent: "pi", model: cell.profile.model }]); + expect(validateRunnerCatalog()).toHaveLength(722); + for (const cell of cells) expect(buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined(); expect(() => assertRemoteNativeEvidencePrerequisites(cells, {})).toThrow(); }); it("pins the Pi 1 profile and versioned coverage while retaining active Stop identity", () => { - // Pi 1/profile 12 changes profile-bearing definitions. Coverage v4/v2 adds - // provider death, pending restart and the strict file oracle; no runtime admission is promoted. + // Current profile identity and native/control coverage retain their own fingerprints. + // Extended v3 states the strict file oracle's task-wide Bash limit. const pi = runnerMatrix.find(c => c.profile.qualificationCandidate === "pi")!.profile; expect(pi.modelQualification?.qualificationId).toBe("pi:0.0.33:1.0.0:openrouter"); - expect(runnerSuites.find(s => s.id === "pi-native")!.definitionMetadata).toMatchObject({ version: 4, profileVersion: 12 }); - expect(runnerSuites.find(s => s.id === "extended-harnesses")!.definitionMetadata).toMatchObject({ version: 2 }); + expect(runnerSuites.find(s => s.id === "pi-native")!.definitionMetadata).toMatchObject({ version: 23, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", agentMemoryParent: "public-managed-file-seed-before-admission", incompleteTerminalCleanup: "retirement-retained-with-failed-watch", profileVersion: 19, agentMemoryContent: "utf8-nonce-plus-final-lf", agentMemoryPrompt: "single-json-write-and-content-bound-native-read-both-runs", agentMemoryReadAuthority: "local-withheld-or-exact-remote-agent-run-file", taskPromptTransport: "fenced-markdown-paste-and-multiline-literal-escapes", nativeFinish: "current-contract-objective-evidence-refs", providerFaultExecutable: "stable-preinstalled-runner-link-and-snapshot-node-inode-with-held-bootstrap-fd-3-or-7" }); + expect(runnerSuites.find(s => s.id === "pi-controls")!.definitionMetadata).toMatchObject({ version: 10, remoteProcExit: "separately-confirmed-absence-after-read-failure", taskCreation: "explicit-title-and-creation-response-id", profileVersion: 19, + remoteProcessIdentity: "observer-pid-startTicks-bootId", + controlPlaneSettlement: "required-scoped-result-and-terminal-after-runner" }); + expect(runnerSuites.find(s => s.id === "extended-harnesses")!.definitionMetadata).toMatchObject({ version: 5, piFileArtifactTitle: "exact-filename", piFileCommandTransport: "fenced-bash-markdown-paste" }); + for (const cell of runnerMatrix.filter(cell => cell.profile.qualificationCandidate === "pi")) { + const agent = cell.profile.buildAgent({ environmentId: "environment", environmentFixtureId: cell.environment.id, workspacePath: "/workspace", executionId: cell.id, secretRefs: { OPENROUTER_API_KEY: { type: "secret_ref", secretId: "synthetic", version: "latest" } } }); + expect(agent.adapterConfig).toMatchObject({ piThinkingLevel: "low" }); + } const hashes = Object.fromEntries(runnerSuites.filter(s => ["pi-native", "native-active-stop", "extended-harnesses", "rich-acp-warm-continuity"].includes(s.id)).map(s => [s.id, suiteDefinitionHash(s)])); expect(hashes).toEqual({ - "pi-native": "eec3b2c140d81561e69c13c1b193b66f7790af0cfc90bbed76c57af2d1e3cdde", - "native-active-stop": "99682b2b106d816a011834fae5a944ed7729958893709d5b83a19b6f595e7e4d", - "rich-acp-warm-continuity": "25e69f031696aeb459e6722a056ae0802379f9137799648fdd1945881f32f41a", - "extended-harnesses": "e50f79cf604bffb2cdee3ad4e8bc1d106045a4f44ef07e877032ff5b8960c8ab", + "pi-native": "90f2e901d7f39bfb6bbadcf63bc78e410a3b071af980f3a23979747e3d5f2021", + "native-active-stop": "2d4fdeeb75bd531b309bd1b35cfa5680ceefdeee6b7155b068dd011ce9901980", + "rich-acp-warm-continuity": "331b88d9538a132670789fb7864df7df5f4bf294ae19b62f53d8a02f901774f0", + "extended-harnesses": "5de4fac78d4d581bc0954f07b5cf2df2862d37f8b0205325eede6d9ef6d9f5e6", }); expect(runnerMatrix.filter(c => c.profile.qualificationCandidate === "pi" && c.suite.id !== "pi-controls")).toHaveLength(22); }); diff --git a/tests/runner-e2e/pi-controls-evidence.ts b/tests/runner-e2e/pi-controls-evidence.ts index 6b51e604a0..5425bd2627 100644 --- a/tests/runner-e2e/pi-controls-evidence.ts +++ b/tests/runner-e2e/pi-controls-evidence.ts @@ -2,14 +2,15 @@ import { createHash } from "node:crypto"; import { canonicalJson } from "../../packages/shared/src/portability-hash.js"; import { hasAcpxNativeOrigin } from "./acpx-native-origin.js"; import { bootstrapReadExecutionId } from "./native-bootstrap-read-proof.js"; +import { withoutApprovedPiBootstrapRequests, type PiBootstrapApproval } from "./pi-bootstrap-permission.js"; import { isValidNativePrpEnvelope } from "./native-event-envelope.js"; import type { ActiveStopCaller } from "./native-active-stop-evidence.js"; type Row = Record; export interface PiControlScope { companyId: string; issueId: string; runId: string; target: string } -export interface PiControlState { run: Row; issue: Row; events: readonly unknown[] } +export interface PiControlState { run: Row; issue: Row; events: readonly unknown[]; bootstrapApproval?: PiBootstrapApproval } export interface PiControlPending { - schema: "paperclip.e2e.pi-control-pending.v1"; scope: PiControlScope; requestId: string; toolCallId: string; executionId: string; + schema: "paperclip.e2e.pi-control-pending.v1"; bootstrapApproval?: PiBootstrapApproval; scope: PiControlScope; requestId: string; toolCallId: string; executionId: string; turnId: string; normalizedSessionId: string; sourceInstanceId: string; itemId: string; requestSourceSeq: number; startedSourceSeq: number; requestRowSha256: string; startedRowSha256: string; observedMonotonicNs: string; } @@ -19,20 +20,26 @@ const hash = (v: unknown) => `sha256:${createHash("sha256").update(canonicalJson function requireProof(value: unknown, reason: string): asserts value { if (!value) throw new Error(`Pi controls: ${reason}`); } const terminalTypes = new Set(["turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"]); const closureTypes = new Set(["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"]); +const controlSettlementTypes = new Set(["run.result.accepted", "run.terminal"]); /** Consume the actual Product projection. Pi does not emit Cursor/Copilot native * diagnostic notices; never manufacture those to reuse another provider's oracle. */ -function origin(events: readonly unknown[], scope: PiControlScope) { +function origin(events: readonly unknown[], scope: PiControlScope, bootstrapApproval?: PiBootstrapApproval) { + if (bootstrapApproval) requireProof(bootstrapApproval.companyId === scope.companyId && bootstrapApproval.issueId === scope.issueId && bootstrapApproval.runId === scope.runId, "foreign bootstrap approval"); + const gradingRows = new Set(withoutApprovedPiBootstrapRequests(events, bootstrapApproval)); requireProof(Object.values(scope).every(id) && events.length > 0 && events.length <= 20_000, "bounded exact scope required"); - const rows = events.map(rec).filter(row => rec(row.payload).prpEvent !== undefined).map(row => ({ row, event: rec(rec(row.payload).prpEvent) })).sort((a, b) => a.row.seq - b.row.seq); + const projected = events.map(rec).filter(row => rec(row.payload).prpEvent !== undefined).map(row => ({ row, event: rec(rec(row.payload).prpEvent) })).sort((a, b) => a.row.seq - b.row.seq); const seqs = new Set(), sourceIds = new Set(), last = new Map(); - for (const { row, event: e } of rows) { + for (const { row, event: e } of projected) { requireProof(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 && !seqs.has(row.seq) + && (e.sourceKind === "runner" || (e.sourceKind === "control_plane" && controlSettlementTypes.has(e.eventType))) + && e.runId === scope.runId && e.eventType === row.eventType && Number.isSafeInteger(row.seq) && row.seq > 0 && !seqs.has(row.seq) && id(e.sourceInstanceId) && Number.isSafeInteger(e.sourceSeq) && e.sourceSeq > (last.get(e.sourceInstanceId) ?? 0) && e.sourceEventId === `${e.sourceInstanceId}:${e.runId}:${e.sourceSeq}` && !sourceIds.has(e.sourceEventId), "foreign, duplicate or reordered event"); seqs.add(row.seq); sourceIds.add(e.sourceEventId); last.set(e.sourceInstanceId, e.sourceSeq); } + const rows = projected.filter(x => x.event.sourceKind === "runner" && gradingRows.has(x.row)); + const control = projected.filter(x => x.event.sourceKind === "control_plane"); const created = rows.filter(x => x.event.eventType === "runtime_request.created"); requireProof(created.length === 1, "one native permission required"); const card = created[0]!, request = rec(rec(card.event.payload).request); @@ -42,36 +49,54 @@ function origin(events: readonly unknown[], scope: PiControlScope) { && Array.isArray(request.choices) && request.choices.some((c: Row) => c.key === "decline"), "native permission identity missing"); const stream = (event: Row) => event.turnId === card.event.turnId && event.normalizedSessionId === card.event.normalizedSessionId && event.sourceInstanceId === card.event.sourceInstanceId; requireProof(rows.filter(x => x.event.turnId != null).every(x => stream(x.event)), "foreign turn, session or producer"); + // Product settlement adds a separate control-plane producer after the runner + // terminal. Validate those records without using them as native tool proof. + const terminals = rows.filter(x => terminalTypes.has(x.event.eventType)); + requireProof(control.length <= 2 && new Set(control.map(x => x.event.eventType)).size === control.length + && control.every(x => terminals.length === 1 && x.row.seq > terminals[0]!.row.seq + && x.event.sourceInstanceId === `${card.event.sourceInstanceId}:control` + && x.event.turnId === card.event.turnId && x.event.normalizedSessionId === card.event.normalizedSessionId + && (x.event.eventType === "run.result.accepted" + ? rec(rec(x.event.payload).result).schema === "paperclip.run_result.v1" + : rec(x.event.payload).schema === "paperclip.prp.terminal.v1")) + && (control.length !== 2 || (control[0]!.event.eventType === "run.result.accepted" && control[1]!.event.eventType === "run.terminal")), + "foreign, duplicate or premature control-plane settlement"); const executionId = bootstrapReadExecutionId(request.details.toolCallId); const native = rows.filter(x => x.event.eventType.startsWith("tool.execution.") && rec(x.event.payload).transport === "builtin"); const write = native.filter(x => rec(x.event.payload).executionId === executionId); const started = write.filter(x => x.event.eventType === "tool.execution.started"); + // Pi streams native tool arguments. The start and early progress rows can + // have no path yet; the pending permission is admissible only after this + // same execution provides the exact target. Conflicting paths and loss of a + // previously known path still fail, including after denial or cancellation. + const targetIndex = write.findIndex(x => x.event.payload.target === scope.target); requireProof(started.length === 1 && write.filter(x => x.event.eventType === "tool.execution.completed").length <= 1 - && write[0] === started[0] - && write.every(x => ["tool.execution.started", "tool.execution.progressed", "tool.execution.completed"].includes(x.event.eventType) + && write[0] === started[0] && targetIndex >= 0 + && write.every((x, index) => ["tool.execution.started", "tool.execution.progressed", "tool.execution.completed"].includes(x.event.eventType) && x.event.payload.schema === "paperclip.tool.execution.v1" && x.event.payload.name === "write" && x.event.payload.operation === "edit" - && x.event.payload.target === scope.target && x.event.payload.status === (x.event.eventType === "tool.execution.completed" ? "failed" : "running")), "exact native write lifecycle missing"); + && (x.event.payload.target === scope.target || (index < targetIndex && x.event.payload.target === null && x.event.eventType !== "tool.execution.completed")) + && x.event.payload.status === (x.event.eventType === "tool.execution.completed" ? "failed" : "running")), "exact native write lifecycle missing"); requireProof(!write.some((x, index) => x.event.eventType === "tool.execution.completed" && index !== write.length - 1), "write activity after terminal"); // Read-only orientation/bootstrap can precede the write. Never exempt another // edit, shell execution, or unknown native operation as an alleged bootstrap. requireProof(native.every(x => rec(x.event.payload).executionId === executionId || (x.event.payload.operation === "read" && x.row.seq < Math.min(card.row.seq, started[0]!.row.seq))), "extra native operation"); - return { rows, card, request, started: started[0]!, executionId, stream }; + return { rows, control, card, request, started: started[0]!, executionId, stream }; } export function observePiControlPending(input: PiControlState & { scope: PiControlScope }): PiControlPending { - const { run, issue, scope } = input, p = origin(input.events, scope); + const { run, issue, scope } = input, p = origin(input.events, scope, input.bootstrapApproval); requireProof(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"); requireProof(!p.rows.some(x => terminalTypes.has(x.event.eventType) || closureTypes.has(x.event.eventType) || (x.event.eventType === "tool.execution.completed" && x.event.payload.executionId === p.executionId)), "permission already answered or provider settled"); - return { schema: "paperclip.e2e.pi-control-pending.v1", scope, requestId: p.request.requestId, toolCallId: p.request.details.toolCallId, executionId: p.executionId, + return { schema: "paperclip.e2e.pi-control-pending.v1", ...(input.bootstrapApproval ? { bootstrapApproval: input.bootstrapApproval } : {}), scope, requestId: p.request.requestId, toolCallId: p.request.details.toolCallId, executionId: p.executionId, turnId: p.card.event.turnId, normalizedSessionId: p.card.event.normalizedSessionId, sourceInstanceId: p.card.event.sourceInstanceId, itemId: p.request.itemId, requestSourceSeq: p.card.event.sourceSeq, startedSourceSeq: p.started.event.sourceSeq, requestRowSha256: hash(p.card.row), startedRowSha256: hash(p.started.row), observedMonotonicNs: process.hrtime.bigint().toString() }; } function retained(input: PiControlState & { pending: PiControlPending }) { const b = input.pending; requireProof(b.schema === "paperclip.e2e.pi-control-pending.v1", "pending receipt missing"); - const p = origin(input.events, b.scope); + const p = origin(input.events, b.scope, b.bootstrapApproval); requireProof(hash(p.card.row) === b.requestRowSha256 && hash(p.started.row) === b.startedRowSha256 && p.card.event.sourceSeq === b.requestSourceSeq && p.started.event.sourceSeq === b.startedSourceSeq && p.request.requestId === b.requestId && p.request.details.toolCallId === b.toolCallId && p.executionId === b.executionId && p.request.itemId === b.itemId && p.card.event.turnId === b.turnId && p.card.event.normalizedSessionId === b.normalizedSessionId && p.card.event.sourceInstanceId === b.sourceInstanceId, "retained pending identity changed"); @@ -116,6 +141,11 @@ export function readPiSteeringAcknowledgement(input: PiControlState & { pending: } export function readPiSteeringSettlement(input: PiControlState & { pending: PiControlPending; commentId: string; queueId: string; marker: string; finalMessage: string }) { const ack = readPiSteeringAcknowledgement(input), p = retained(input), b = input.pending; + const accepted = rec(rec(p.control[0]?.event.payload).result), terminalResult = rec(p.control[1]?.event.payload); + requireProof(p.control.length === 2 && p.control[0]!.event.eventType === "run.result.accepted" && p.control[1]!.event.eventType === "run.terminal" + && accepted.reportedWorkDisposition === "done" && accepted.summary === input.marker + && terminalResult.runTerminalState === "succeeded" && terminalResult.turnTerminalState === "completed" && terminalResult.reportedWorkDisposition === "done", + "complete matching control-plane settlement required"); const closed = p.rows.filter(x => closureTypes.has(x.event.eventType)), terminal = p.rows.filter(x => terminalTypes.has(x.event.eventType)); const failed = p.rows.filter(x => x.event.eventType === "tool.execution.completed" && x.event.payload.executionId === b.executionId); requireProof(closed.length === 1 && closed[0]!.event.eventType === "runtime_request.resolved" && closed[0]!.event.payload.requestId === b.requestId && closed[0]!.event.payload.turnId === b.turnId diff --git a/tests/runner-e2e/pi-controls-flow.test.ts b/tests/runner-e2e/pi-controls-flow.test.ts index 13bc39b7d1..3944871670 100644 --- a/tests/runner-e2e/pi-controls-flow.test.ts +++ b/tests/runner-e2e/pi-controls-flow.test.ts @@ -8,10 +8,12 @@ import { runPiControlsFlow } from "./pi-controls-flow.js"; import { persistedFinalRunMessage } from "./matchers.js"; import { readPiSteeringSettlement } from "./pi-controls-evidence.js"; -const harness = vi.hoisted(() => ({ target: "", prompt: "", message: "", mutation: false, incomplete: false })); +vi.mock("./pi-bootstrap-permission.js", async importOriginal => ({ ...await importOriginal(), approvePiBootstrapRead: async () => undefined })); + +const harness = vi.hoisted(() => ({ target: "", prompt: "", message: "", foreignCreatedTask: false, mutation: false, incomplete: false })); vi.mock("./user-actions.js", () => ({ - createTaskThroughUi: async (input: { prompt: string }) => { harness.prompt = input.prompt; }, - submitTaskReply: async (_page: unknown, body: string) => { harness.message = body; return Date.now(); }, + createTaskThroughUi: async (input: { prompt: string; requireExplicitTitle?: boolean }) => { expect(input.requireExplicitTitle).toBe(true); harness.prompt = input.prompt; return { submittedAtMs: Date.now(), issueId: harness.foreignCreatedTask ? "foreign-created-task" : "issue" }; }, + submitTaskReply: async (page: { submitReply(body: string): void }, body: string) => { page.submitReply(body); return Date.now(); }, })); vi.mock("./copilot-local-fixtures.js", async importOriginal => { const actual = await importOriginal(); @@ -26,8 +28,8 @@ vi.mock("@playwright/test", () => ({ expect: (actual: any, message?: string) => toHaveCount: async (value: number) => { if (typeof actual.count === "function") expect(actual.count()).toBe(value); }, }) })); -async function exercise(taskId: string, remote: boolean, failure?: "mutation" | "incomplete" | "missing-ack" | "missing-comment" | "foreign-comment") { - harness.target = ""; harness.prompt = ""; harness.message = ""; harness.mutation = failure === "mutation"; harness.incomplete = failure === "incomplete"; +async function exercise(taskId: string, remote: boolean, failure?: "mutation" | "incomplete" | "missing-ack" | "missing-comment" | "foreign-comment" | "foreign-queued-body" | "steer-rejected" | "duplicate-permission" | "annotation-drift" | "root-rotation" | "foreign-created-task") { + harness.foreignCreatedTask = failure === "foreign-created-task"; harness.target = ""; harness.prompt = ""; harness.message = ""; harness.mutation = failure === "mutation"; harness.incomplete = failure === "incomplete"; const task = piControlTasks.find(t => t.id === taskId)!, stopCase = taskId === "pending-permission-stop"; const workspacePath = await mkdtemp(join(tmpdir(), "pi-controls-fixture-")); const saved = new Map(), localCleanup: Array<() => Promise> = [], remoteCleanup: Array<() => Promise> = []; @@ -37,9 +39,9 @@ async function exercise(taskId: string, remote: boolean, failure?: "mutation" | const publicComments: any[][] = []; const sync = () => { const target = remote ? "pi-control-fixture.txt" : harness.target; - f.scope.target = target; f.tool.target = target; f.issue.title = task.buildTitle("fixture"); + f.scope.target = target; f.tool.target = target; f.issue.title = "Provider-generated task name"; f.issue.companyId = "company"; f.issue.assigneeAgentId = "agent"; }; - const queue = () => ({ queueId: "queue", targetRunId: "run", revision: "revision", protocol: "paperclip_runner_v1", steeringDisposition: "available", entries: harness.message ? [{ comment: { id: "comment", body: harness.message } }] : [] }); + const queue = () => ({ queueId: "queue", targetRunId: "run", revision: "revision", protocol: "paperclip_runner_v1", steeringDisposition: "available", entries: harness.message ? [{ comment: { id: "comment", body: harness.message + (failure === "foreign-queued-body" ? " altered" : "") } }] : [] }); const api = { post: async (path: string, body: any) => { if (path.endsWith("/projects")) return { name: "Pi controls fixture" }; @@ -55,7 +57,10 @@ async function exercise(taskId: string, remote: boolean, failure?: "mutation" | if (path.endsWith("/issues?limit=100")) return [f.issue]; if (path === "/api/issues/issue") return f.issue; if (path.endsWith("/heartbeat-runs?limit=100")) return [f.run]; - if (path === "/api/heartbeat-runs/run") return f.run; + if (path === "/api/heartbeat-runs/run") { + if (failure === "annotation-drift") f.run.processStartedAt = f.run.status === "running" ? "2026-10-01T00:00:11Z" : "2026-10-01T00:00:00Z"; + return f.run; + } if (path.includes("/events?")) return f.events; if (path.endsWith("/queued-comments")) return queue(); if (path.endsWith("/comments")) { @@ -77,13 +82,21 @@ async function exercise(taskId: string, remote: boolean, failure?: "mutation" | }; const waiters: Array<{ predicate: (request: any) => boolean; resolve: (request: any) => void }> = []; function postBrowser(path: string, body: unknown) { - const request = { url: () => `http://fixture${path}`, method: () => "POST", postDataJSON: () => body }; + const rejected = failure === "steer-rejected" && path.endsWith("/steer"); + const request = { url: () => `http://fixture${path}`, method: () => "POST", postDataJSON: () => body, + response: async () => ({ ok: () => !rejected, status: () => rejected ? 409 : 200 }) }; const waiter = waiters.find(w => w.predicate(request)); if (!waiter) throw new Error("Browser request was not awaited"); waiter.resolve(request); waiters.splice(waiters.indexOf(waiter), 1); } - const locator = (kind = "other"): any => ({ - filter: () => locator(kind), last: () => locator(kind), getByText: () => locator(), - count: () => kind === "loading" ? 0 : kind === "card" ? 1 : kind === "deny" ? (f.run.status === "running" ? 1 : 0) : 1, + const locator = (kind = "other", actionable = false): any => ({ + filter: (options: { has?: { kind: string } }) => { + if (kind === "card" && options.has) expect(options.has.kind).toBe("deny"); + return locator(kind, actionable || Boolean(options.has)); + }, last: () => locator(kind, actionable), getByText: () => locator(), + kind, + // Model the production transcript: the resolved setup card stays visible + // beside the pending write, and has no actionable decision buttons. + count: () => kind === "loading" ? 0 : kind === "card" ? (actionable ? (failure === "duplicate-permission" ? 2 : 1) : (remote ? 2 : 1)) : kind === "deny" ? (f.run.status === "running" ? 1 : 0) : 1, getByRole: (_role: string, options: { name: string }) => locator(options.name === "Deny" ? "deny" : "other"), getByTestId: () => locator(), click: async () => { @@ -95,13 +108,21 @@ async function exercise(taskId: string, remote: boolean, failure?: "mutation" | if (failure === "missing-ack") f.run.resultJson.queuedSteeringAcknowledgements = {}; postBrowser("/api/issues/issue/queued-comments/comment/steer", { queueId: "queue", targetRunId: "run", revision: "revision" }); } else if (kind === "deny") { - browserDeclines++; expect(browserSteers).toBe(1); expect(stopCase).toBe(false); f.finish(); + browserDeclines++; expect(browserSteers).toBe(1); expect(stopCase).toBe(false); f.finish(steeringMarker); f.run.resultJson.presentationDecision = { commentId: "final", reason: "fixture-public-presentation" }; postBrowser("/api/heartbeat-runs/run/runtime-requests/request/resolve", { turnId: "turn", requestKind: "permission_approval", resolution: { action: "decline" } }); } else throw new Error(`Unexpected click ${kind}`); }, }); const page = { goto: async () => {}, reload: async () => {}, getByText: () => locator(), + getByRole: (_role: string, options: { name: string }) => locator(options.name === "Deny" ? "deny" : "other"), + submitReply: (body: string) => { + // Match the production editor's retained Markdown escaping. Matching the + // plain input would never observe this otherwise valid queued comment. + harness.message = body.replaceAll("_", "\\\\_").replaceAll("[]", "\\\\[]"); + expect(harness.message).not.toBe(body); + postBrowser("/api/issues/issue/comments", { body: harness.message }); + }, getByTestId: (id: string) => locator(id === "task-chat-runtime-request" ? "card" : id.startsWith("task-chat-queued-steer-") ? "steer" : id === "task-chat-history-loading" ? "loading" : "other"), waitForRequest: (predicate: (request: any) => boolean) => new Promise(resolve => waiters.push({ predicate, resolve })) }; // The real shared remote oracle is exercised. Poisoned local copyback cannot @@ -112,7 +133,7 @@ async function exercise(taskId: string, remote: boolean, failure?: "mutation" | binding, observedAtMs: ++remoteSequence, receivedAtMs: remoteSequence + 1, observedMonotonicNs: String(remoteSequence), complete: true, workspace: {}, targets: { "pi-control-fixture.txt": { absent: true, sha256: null, complete: true, mutationCount: retired && harness.mutation ? 1 : 0, parent: { dev: "1", ino: "2" } } }, watcher: { complete: !(retired && harness.incomplete), targetMutationCount: retired && harness.mutation ? 1 : 0, workspaceMutationCount: 0 }, - processes: { captured: true, root, journal: [root], live: retired ? [] : [50] }, + processes: { captured: true, root: retired && failure === "root-rotation" ? { ...root, startTicks: "201" } : root, journal: [root], live: retired ? [] : [50] }, setup: { path: ".paperclip-eval-action-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.txt", sha256: published ? `sha256:${"b".repeat(64)}` : null, published }, attached: null, }); let seal: ReturnType | undefined; @@ -126,8 +147,12 @@ async function exercise(taskId: string, remote: boolean, failure?: "mutation" | execution: { task, suite: { id: "pi-controls" }, environment: { id: remote ? "daytona" : "local" }, profile: { qualificationCandidate: "pi" } }, workspacePath, nonce: "fixture", deadlineAt: Date.now() + 1000, observe: () => {}, capture: async () => {}, evidence: async (name: string, value: unknown) => saved.set(name, structuredClone(value)), remoteBootstrap, registerCleanupAssertion: (fn: () => Promise) => localCleanup.push(fn), registerBeforeEnvironmentTeardownAssertion: (fn: () => Promise) => remoteCleanup.push(fn) } as any); - if (failure === "missing-ack") { await expect(call).rejects.toThrow("acknowledgement"); expect(browserDeclines).toBe(0); } - else if (remote && (failure === "mutation" || failure === "incomplete")) await expect(call).rejects.toThrow(); + if (failure === "foreign-created-task") { await expect(call).rejects.toThrow("Unexpected read /api/issues/foreign-created-task"); expect(stops).toBe(0); expect(browserDeclines).toBe(0); expect(browserSteers).toBe(0); } + else if (failure === "duplicate-permission") { await expect(call).rejects.toThrow(); expect(stops).toBe(0); expect(browserDeclines).toBe(0); expect(browserSteers).toBe(0); } + else if (failure === "missing-ack") { await expect(call).rejects.toThrow("acknowledgement"); expect(browserDeclines).toBe(0); } + else if (failure === "steer-rejected") { await expect(call).rejects.toThrow("steering rejected: HTTP 409"); expect(browserDeclines).toBe(0); } + else if (failure === "foreign-queued-body") { await expect(call).rejects.toThrow("browser comment queued"); expect(browserSteers).toBe(0); expect(browserDeclines).toBe(0); } + else if (remote && (failure === "mutation" || failure === "incomplete" || failure === "root-rotation")) await expect(call).rejects.toThrow(); else { const result = await call; expect(result.checks.every(c => c.passed)).toBe(true); expect(saved.has("api-state.json")).toBe(true); expect(stops).toBe(stopCase ? 1 : 0); expect(staleDeclines).toBe(stopCase ? 1 : 0); @@ -145,8 +170,9 @@ async function exercise(taskId: string, remote: boolean, failure?: "mutation" | expect(remote ? localCleanup : remoteCleanup).toHaveLength(0); const cleanups = remote ? remoteCleanup : localCleanup; expect(cleanups).toHaveLength(1); cleaningUp = true; - if (failure) await expect(cleanups[0]!()).rejects.toThrow(); + if (failure && !(remote && failure === "annotation-drift")) await expect(cleanups[0]!()).rejects.toThrow(); else expect((await cleanups[0]!())[0].passed).toBe(true); + if (failure === "annotation-drift") expect(saved.get("pi-control-cleanup.json").processError).toBe(!remote); if (!stopCase && (!failure || failure === "missing-comment" || failure === "foreign-comment")) { const receipt = saved.get("pi-steering-presentation-after-cleanup.json"); expect(receipt.phase).toBe("after-cleanup"); expect(receipt.comments).toEqual(publicComments.at(-1)); @@ -165,4 +191,12 @@ for (const task of ["pending-permission-stop", "same-turn-steering"]) { it.each(["mutation", "incomplete"] as const)("fails local cleanup on %s despite an absent final target", failure => exercise("pending-permission-stop", false, failure)); it.each(["mutation", "incomplete"] as const)("fails remote lifetime proof on %s despite an absent target", failure => exercise("same-turn-steering", true, failure)); it("never denies the native write before the steering acknowledgement exists", () => exercise("same-turn-steering", false, "missing-ack")); +it("rejects a queued body that differs from the exact browser submission", () => exercise("same-turn-steering", false, "foreign-queued-body")); +it("stops before denial when the real steering API rejects the request", () => exercise("same-turn-steering", false, "steer-rejected")); it.each(["missing-comment", "foreign-comment"] as const)("retains and rejects %s during cleanup without fabricating persisted output", failure => exercise("same-turn-steering", false, failure)); +it("refuses control when two actionable permission cards remain beside resolved setup history", () => exercise("pending-permission-stop", true, "duplicate-permission")); +it.each(["pending-permission-stop", "same-turn-steering"])("%s retains exact remote birth identity when public timestamp annotations change", task => exercise(task, true, "annotation-drift")); +it("rejects actual remote process birth rotation despite unchanged public annotations", () => exercise("pending-permission-stop", true, "root-rotation")); +it("still rejects local process authority timestamp changes", () => exercise("pending-permission-stop", false, "annotation-drift")); + +it("refuses a task absent from the browser creation response instead of selecting a title match", () => exercise("pending-permission-stop", false, "foreign-created-task")); diff --git a/tests/runner-e2e/pi-controls-flow.ts b/tests/runner-e2e/pi-controls-flow.ts index d8fab26fef..dcfb126de7 100644 --- a/tests/runner-e2e/pi-controls-flow.ts +++ b/tests/runner-e2e/pi-controls-flow.ts @@ -8,6 +8,7 @@ import { createDeniedTargetFixture, exists, observeRunProcesses } from "./copilo import { assertCopilotRemoteRetirement, copilotRemoteDeniedSample, prepareCopilotRemoteAction, type CopilotRemoteBootstrap, type CopilotRemoteFixture, type CopilotRemoteSnapshot } from "./copilot-protection-evidence.js"; import { assertActiveStopRetirement, readActiveStopRemoteRetirement, readActiveStopCaller, type ActiveStopCaller, type ActiveStopRemoteObservation } from "./native-active-stop-evidence.js"; import { assertSamePiPending, observePiControlPending, readPiStopSettlement, readPiSteeringAcknowledgement, readPiSteeringSettlement, type PiControlPending, type PiControlScope, type PiControlState } from "./pi-controls-evidence.js"; +import { approvePiBootstrapRead, type PiBootstrapApproval } from "./pi-bootstrap-permission.js"; import { piNativeFinish } from "./pi-native-cases.js"; import type { LiveFixtureValues } from "./live-fixtures.js"; import type { MatrixExecution } from "./types.js"; @@ -51,6 +52,7 @@ export async function runPiControlsFlow(input: { || !["pending-permission-stop", "same-turn-steering"].includes(execution.task.id)) throw new Error("Unknown Pi control case"); const stopCase = execution.task.id === "pending-permission-stop", remote = execution.environment.id === "daytona"; if ((!remote && execution.environment.id !== "local") || (remote && !input.remoteBootstrap)) throw new Error("Pi controls require an isolated admitted environment"); + let bootstrapApproval: PiBootstrapApproval | undefined; 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 = `pi-control-${nonce}.txt`, local = remote ? undefined : await createDeniedTargetFixture(input.workspacePath, name), target = local?.targetRelativePath ?? name; @@ -62,9 +64,13 @@ export async function runPiControlsFlow(input: { let steered: { pending: PiControlPending; commentId: string; queueId: string; marker: string } | undefined; let completed = false; const observeProcesses = () => { + // Controller timestamps describe remote launch annotations, not Linux + // process birth. Daytona identity is checked in the bound remote snapshots + // through PID, start ticks and boot ID, including the retirement seal. + if (!observer) return; 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); + processes = observer.sample(authority); }; const load = async (): Promise => { if (issue.id) issue = await api.get(`/api/issues/${issue.id}`); @@ -72,7 +78,7 @@ export async function runPiControlsFlow(input: { runs = await Promise.all(list.map(run => api.get(`/api/heartbeat-runs/${run.id}`))); if (runs.length > 1) throw new Error("Stopped waiting for Pi controls: extra provider 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 }; + observeProcesses(); input.observe(issue, runs); return { run: runs[0] ?? {}, issue, events, bootstrapApproval }; }; const scope = (): PiControlScope => ({ companyId: fixtures.company.id, issueId: issue.id, runId: runs[0]!.id, target }); const readPresentation = async (state: PiControlState) => { @@ -140,8 +146,9 @@ export async function runPiControlsFlow(input: { check("explicit-per-turn-policy", configured.adapterConfig?.acpxPermissionMode === "approve-reads" && configured.adapterConfig?.lifecycleMode === "per_turn", "Native write requires a human decision before startup"); const project = await api.post(`/api/companies/${fixtures.company.id}/projects`, { name: `Pi controls ${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 Pi control 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 }))!; + const createdTask = 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, requireExplicitTitle: true }); + issue = await api.get(`/api/issues/${createdTask.issueId}`); + check("created-task-identity", issue.id === createdTask.issueId && issue.companyId === fixtures.company.id && issue.assigneeAgentId === fixtures.agent.id, "Creation response binds the exact company-scoped assigned task before native control"); if (remote) { await pollUntil({ label: "Pi control 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 => { @@ -151,12 +158,18 @@ export async function runPiControlsFlow(input: { await input.evidence("pi-control-before-request-remote.json", baseline); return prepared.prompt; } }); if (bound !== fixture) throw new Error("Pi remote action identity changed"); + bootstrapApproval = await approvePiBootstrapRead({ api, fixture: bound, companyId: fixtures.company.id, issueId: issue.id, runId: runs[0]!.id, + deadlineAt: input.deadlineAt, load, evidence: input.evidence }); } await pollUntil({ label: "Pi unanswered native write", 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 Pi unanswered native write: no active pending permission"); return state; }, accept: state => { observePiControlPending({ ...state, scope: scope() }); return true; } }); await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`); - const card = page.getByTestId("task-chat-runtime-request").filter({ visible: true }); + // A resolved bootstrap read remains visible in the transcript. Only the + // pending permission has decision buttons; the public event oracle and + // browser POST below still bind the decision to the exact write request. + const card = page.getByTestId("task-chat-runtime-request").filter({ visible: true }) + .filter({ has: page.getByRole("button", { name: "Deny", exact: true }) }); await expect(card).toHaveCount(1); await expect(card.getByRole("button", { name: "Deny", exact: true })).toBeEnabled(); await input.capture("pending-permission", "Pi write waits for a human decision", "pending-permission.png"); await sample("pending"); if (stopCase) { @@ -172,10 +185,16 @@ export async function runPiControlsFlow(input: { await input.evidence("pi-control-pending.json", pending); const marker = `PI-STEER-${randomBytes(16).toString("hex")}`; const message = `After the actual native denial, complete this negative test. Do not write or invoke any other native tool. ${piNativeFinish(marker)}`; + const commentRoutes = new Set([issue.id, issue.identifier].filter(Boolean).map(id => `/api/issues/${id}/comments`)); + const submitted = page.waitForRequest(request => commentRoutes.has(new URL(request.url()).pathname) && request.method() === "POST"); await submitTaskReply(page, message); + // The production rich-text editor serializes plain input as Markdown. + // Bind the queued comment to the exact body actually sent by the browser. + const submittedBody = (await submitted).postDataJSON()?.body; + check("browser-steering-content", typeof submittedBody === "string" && submittedBody.startsWith("After the actual native denial, complete this negative test. Do not write or invoke any other native tool. ") && submittedBody.includes(marker), "Browser submitted the hidden instruction while permission remains pending"); const queue = await pollUntil({ label: "Pi browser comment queued for steering", deadlineAt: input.deadlineAt, intervalMs: 200, - load: () => api.get(`/api/issues/${issue.id}/queued-comments`), accept: q => q.steeringDisposition === "available" && q.entries?.some((entry: Row) => entry.comment.body === message) }); - const entries = queue.entries.filter((entry: Row) => entry.comment.body === message); + load: () => api.get(`/api/issues/${issue.id}/queued-comments`), accept: q => q.steeringDisposition === "available" && q.entries?.some((entry: Row) => entry.comment.body === submittedBody) }); + const entries = queue.entries.filter((entry: Row) => entry.comment.body === submittedBody); check("one-browser-steering-message", entries.length === 1 && queue.entries.length === 1 && queue.targetRunId === pending.scope.runId, "One browser-originated comment targets the pending run"); const commentId = entries[0].comment.id, queueId = queue.queueId; assertSamePiPending(pending, observePiControlPending({ ...await load(), scope: scope() })); @@ -183,8 +202,11 @@ export async function runPiControlsFlow(input: { const route = `/api/issues/${issue.id}/queued-comments/${commentId}/steer`; const posted = page.waitForRequest(request => new URL(request.url()).pathname === route && request.method() === "POST"); await page.getByTestId(`task-chat-queued-steer-${commentId}`).click(); - const body = (await posted).postDataJSON(); + const steeringRequest = await posted; + const body = steeringRequest.postDataJSON(); check("exact-browser-steer", body.queueId === queueId && body.revision === queue.revision && body.targetRunId === pending.scope.runId, "Browser steers the exact queued comment into the active run"); + const response = await steeringRequest.response(); + if (!response?.ok()) throw new Error(`Pi native steering rejected: HTTP ${response?.status() ?? "missing"}`); steered = { pending, commentId, queueId, marker }; await pollUntil({ label: "Pi same-turn steering acknowledgement", deadlineAt: input.deadlineAt, intervalMs: 200, load, accept: state => { assertSamePiPending(pending, observePiControlPending({ ...state, scope: scope() })); readPiSteeringAcknowledgement({ ...state, ...steered! }); return true; } }); diff --git a/tests/runner-e2e/pi-controls-test-fixture.ts b/tests/runner-e2e/pi-controls-test-fixture.ts index 1f37d49875..f121f06af4 100644 --- a/tests/runner-e2e/pi-controls-test-fixture.ts +++ b/tests/runner-e2e/pi-controls-test-fixture.ts @@ -48,11 +48,17 @@ export function piControlFixture(target = "target.txt") { run.resultJson.queuedSteeringAcknowledgements = { [commentId]: { status: "acknowledged", queueId, turnId: "turn", acknowledgedAt: "2026-10-01T00:00:01Z" } }; append("item.completed", { kind: "steering_acknowledgement", status: "acknowledged", mode: "steer", text: "Steering acknowledged for the active turn." }, { itemId: `turn:steer:${commentId}` }); } - function finish() { + function finish(finalMarker = marker) { append("runtime_request.resolved", { requestId: "request", turnId: "turn", action: "decline" }); append("tool.execution.completed", { ...tool, status: "failed", output: "Pi operation was denied or cancelled" }); append("turn.completed", { status: "completed", error: null }); run.status = "succeeded"; issue.status = "done"; + append("run.result.accepted", { result: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: finalMarker } }, { + sourceKind: "control_plane", sourceInstanceId: "source:control", sourceSeq: 1, sourceEventId: "source:control:run:1", + }); + append("run.terminal", { schema: "paperclip.prp.terminal.v1", runTerminalState: "succeeded", turnTerminalState: "completed", reportedWorkDisposition: "done" }, { + sourceKind: "control_plane", sourceInstanceId: "source:control", sourceSeq: 2, sourceEventId: "source:control:run:2", + }); } return { scope, events, run, issue, state, pending, row, append, request, tool, cancel, responses, steer, finish, marker, commentId, queueId }; } diff --git a/tests/runner-e2e/pi-file-evidence.test.ts b/tests/runner-e2e/pi-file-evidence.test.ts index 212ae8ac9f..0eda6cd77a 100644 --- a/tests/runner-e2e/pi-file-evidence.test.ts +++ b/tests/runner-e2e/pi-file-evidence.test.ts @@ -5,22 +5,28 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { collectPiFileEvidence, gradePiCopyback, gradePiFileEvidence, piFileContract, seedPiFile } from "./pi-file-evidence.js"; -import { canonicalProviderEventsFromAcpxRuntimeEvent } from "../../packages/paperclip-runner/src/provider-events.js"; +import { collectPiFileEvidence, gradePiCopyback, gradePiFileEvidence, piFileContract, piFilePrompt, seedPiFile } from "./pi-file-evidence.js"; +import { canonicalProviderEventsFromAcpxRuntimeEvent, createAcpxToolEventNormalizer } from "../../packages/paperclip-runner/src/provider-events.js"; import { safeAcpxLocations } from "../../packages/paperclip-runner/src/drivers/acpx/safe-locations.js"; import { classifyFailure } from "./failure-classifier.js"; import { runnerMatrix } from "./catalog.js"; function fixture() { const c = piFileContract("fixture"), attachmentId = "12345678-1234-1234-1234-123456789abc"; - const tool = (seq: number, executionId: string, name: string, operation: string, completed: boolean, target: string | null, output = "") => { - // Pi emits absolute edit locations and uses the bash command as title. - // Exercise sidecar path normalization followed by the common projection; - // direct raw ACP projection would incorrectly lose the workspace target. - const canonical = canonicalProviderEventsFromAcpxRuntimeEvent({ type: "tool_call", toolCallId: executionId, - tag: completed ? "tool_call_update" : "tool_call", title: name, kind: operation, - status: completed ? "completed" : "pending", locations: safeAcpxLocations(target ? [{ path: `/workspace/${target}` }] : [], "/workspace", operation, name), rawOutput: output }, executionId, "turn")[0]!; + const normalize = createAcpxToolEventNormalizer(); + const tool = (seq: number, executionId: string, name: string, operation: string, + phase: "start" | "progress" | "end", target: string | null, text = "", rawOutput: unknown = undefined) => { + // Sanitized shape of the retained Pi stream: missing opening location, + // later resolved edit arguments, and stable bash name with command progress. + // This exercises TS normalization/projection, not the Rust implementation. + const event = normalize({ type: "tool_call", toolCallId: executionId, + tag: phase === "start" ? "tool_call" : "tool_call_update", title: name, kind: operation, + status: phase === "end" ? "completed" : phase === "start" ? "pending" : "in_progress", + locations: safeAcpxLocations(target ? [{ path: `/workspace/${target}` }] : [], "/workspace", operation, name), + text, rawOutput }); + const canonical = canonicalProviderEventsFromAcpxRuntimeEvent(event, executionId, "turn")[0]!; return { companyId: "company", runId: "run", seq, protocolSchemaVersion: 1, eventType: canonical.eventType, + sourceInstanceId: "runner", sourceSeq: seq, sourceEventId: `runner:run:${seq}`, payload: { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", sourceInstanceId: "runner", sourceSeq: seq, sourceEventId: `runner:run:${seq}`, runId: "run", turnId: "turn", normalizedSessionId: "session", ...canonical } } }; }; @@ -33,42 +39,87 @@ function fixture() { publish: { operationId: "register_deliverable", input: { filename: c.filename, contentRef: c.filename, contentType: "text/plain", byteSize: c.byteSize, sha256: c.sha256 }, result: { disposition: "applied", commandId: `deliverable-prepared:${attachmentId}`, entityRefs: [attachmentId, "product", "comment"] } }, } } }, - events: [tool(1, "edit", "edit", "edit", false, c.filename), tool(2, "edit", "edit", "edit", true, c.filename), - tool(3, "validate", c.validationCommand, "execute", false, null), tool(4, "validate", c.validationCommand, "execute", true, null, c.validationMarker)], + events: [tool(1, "edit", "edit", "edit", "start", null), + tool(2, "edit", "edit", "edit", "progress", c.filename), + tool(3, "edit", "edit", "edit", "end", c.filename), + tool(4, "validate", "bash", "execute", "start", null, "bash (pending): [terminal] validate"), + tool(5, "validate", c.validationCommand, "execute", "progress", null, `${c.validationCommand} (in_progress): ${c.validationCommand}`), + tool(6, "validate", c.validationCommand, "execute", "end", null, "", { + content: [{ type: "text", text: `${c.validationMarker}\n` }], + structuredContent: { output: `${c.validationMarker}\n`, truncated: false, exit_code: 0, wall_time_seconds: 0 }, + })], attachments: [{ id: attachmentId, companyId: "company", issueId: "issue", originatingRunId: "run", createdByAgentId: "agent", originalFilename: c.filename, contentType: "text/plain", byteSize: c.byteSize, sha256: c.sha256 }], activity: [{ action: "issue.attachment_added", companyId: "company", runId: "run", actorId: "agent", entityId: "issue", details: { attachmentId, source: "paperclip_runner_protocol" } }], }; } +function changeOutput(f: ReturnType, change: (output: any) => void) { + const p = f.events[5]!.payload.prpEvent.payload, output = JSON.parse(p.output as string); + change(output); p.output = JSON.stringify(output); +} describe("Pi edit, validation and public artifact oracle", () => { + it("calibrates against the sanitized 44-row actual native stream without regrading the paid attempt", async () => { + const retained = JSON.parse(await readFile(new URL("./fixtures/pi-file-evidence-actual-stream.json", import.meta.url), "utf8")); + const f = fixture(); + expect(retained.events).toHaveLength(44); + f.events = retained.events.map((x: any) => ({ companyId: "company", runId: "run", seq: x.seq, + protocolSchemaVersion: 1, eventType: x.eventType, sourceInstanceId: "runner", sourceSeq: x.sourceSeq, + sourceEventId: `runner:run:${x.sourceSeq}`, payload: { prpEvent: { + schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", sourceInstanceId: "runner", + sourceSeq: x.sourceSeq, sourceEventId: `runner:run:${x.sourceSeq}`, runId: "run", turnId: "turn", + normalizedSessionId: "session", eventType: x.eventType, payload: x.payload, + } } })); + expect(gradePiFileEvidence(f).verification).toMatchObject({ nativeStructuredExitCode: 0, typedExitCode: null }); + }); it("accepts actual correlated lifecycles, independent bytes and a run-bound registered download", () => { const result = gradePiFileEvidence(fixture()); expect(result.passed).toBe(true); expect(result.diff.text).toContain("-ready-fixture\n+verified-fixture\n"); expect(result.limits.join(" ")).toContain("not attested"); expect(result.verification.providerExecutionStatus).toBe("completed"); - expect(result.verification.commandTitle).toBe(piFileContract("fixture").validationCommand); + expect(result.verification.command).toBe(piFileContract("fixture").validationCommand); + expect(result.verification).toMatchObject({ commandEvidence: "correlated_native_progress", + exitEvidence: "native_structured_output_receipt", nativeStructuredExitCode: 0, typedExitCode: null }); + expect(fixture().events.map(e => e.payload.prpEvent.payload.target)).toEqual([null, "extended-fixture.txt", "extended-fixture.txt", null, null, null]); + expect(fixture().events.slice(3).every(e => e.payload.prpEvent.payload.name === "bash")).toBe(true); expect(result.verification.nativeFileAttribution).toBe("workspace_relative_display_target"); }); + it.each(["before", "after"])("rejects an extra metadata bash call %s validation despite correct file and download bytes", placement => { + const f = fixture(); + const metadata = structuredClone(f.events.slice(3)); + for (const row of metadata) row.payload.prpEvent.payload.executionId = "metadata"; + metadata[1]!.payload.prpEvent.payload.progress = "wc -c extended-fixture.txt (in_progress): wc -c extended-fixture.txt"; + metadata[2]!.payload.prpEvent.payload.output = JSON.stringify({ + content: [{ type: "text", text: "17 extended-fixture.txt\n" }], + structuredContent: { output: "17 extended-fixture.txt\n", truncated: false, exit_code: 0 }, + }); + if (placement === "before") f.events.splice(3, 0, ...metadata); + else f.events.push(...metadata); + for (const [index, row] of f.events.entries()) { + row.seq = row.sourceSeq = row.payload.prpEvent.sourceSeq = index + 1; + row.sourceEventId = row.payload.prpEvent.sourceEventId = `runner:run:${index + 1}`; + } + expect(() => gradePiFileEvidence(f)).toThrow("one observed provider validation execution"); + }); const mutations: Array<[string, (f: ReturnType) => void]> = [ ["absent seed", f => { f.seed = {} as any; }], ["wrong final bytes", f => { f.workspaceBytes = Buffer.from("wrong\n"); }], - ["final-only write", f => { f.events[0]!.payload.prpEvent.payload.name = "write"; f.events[1]!.payload.prpEvent.payload.name = "write"; }], - ["missing edit", f => { f.events = f.events.slice(2); }], - ["missing validation", f => { f.events = f.events.slice(0, 2); }], - ["echo-only validation", f => { f.events[2]!.payload.prpEvent.payload.name = `echo ${piFileContract("fixture").validationMarker}`; f.events[3]!.payload.prpEvent.payload.name = f.events[2]!.payload.prpEvent.payload.name; }], - ["failed validation", f => { f.events[3]!.payload.prpEvent.payload.status = "failed"; }], + ["final-only write", f => { f.events[0]!.payload.prpEvent.payload.name = "write"; f.events[2]!.payload.prpEvent.payload.name = "write"; }], + ["missing edit", f => { f.events = f.events.slice(3); }], + ["missing validation", f => { f.events = f.events.slice(0, 3); }], + ["echo-only validation", f => { f.events[3]!.payload.prpEvent.payload.name = `echo ${piFileContract("fixture").validationMarker}`; f.events[5]!.payload.prpEvent.payload.name = f.events[3]!.payload.prpEvent.payload.name; }], + ["failed validation", f => { f.events[5]!.payload.prpEvent.payload.status = "failed"; }], ["unfinished validation", f => { f.events.pop(); }], - ["unrelated validation turn", f => { f.events[2]!.payload.prpEvent.turnId = "other"; f.events[3]!.payload.prpEvent.turnId = "other"; }], + ["unrelated validation turn", f => { f.events[3]!.payload.prpEvent.turnId = "other"; f.events[5]!.payload.prpEvent.turnId = "other"; }], ["foreign native session", f => { f.run.nativeSessionId = "other"; }], ["foreign native producer", f => { f.run.runnerInstanceId = "other"; }], - ["foreign event run", f => { f.events[1]!.payload.prpEvent.runId = "other"; }], - ["duplicate event", f => { f.events.push(structuredClone(f.events[3]!)); }], - ["missing tool start", f => { f.events.splice(2, 1); }], - ["truncated validation output", f => { f.events[3]!.payload.prpEvent.payload.outputTruncated = true; }], - ["missing native target", f => { f.events[0]!.payload.prpEvent.payload.target = null; f.events[1]!.payload.prpEvent.payload.target = null; }], - ["wrong target", f => { f.events[1]!.payload.prpEvent.payload.target = "other.txt"; }], + ["foreign event run", f => { f.events[2]!.payload.prpEvent.runId = "other"; }], + ["duplicate event", f => { f.events.push(structuredClone(f.events[5]!)); }], + ["missing tool start", f => { f.events.splice(3, 1); }], + ["truncated validation output", f => { f.events[5]!.payload.prpEvent.payload.outputTruncated = true; }], + ["missing native target", f => { f.events[0]!.payload.prpEvent.payload.target = null; f.events[2]!.payload.prpEvent.payload.target = null; }], + ["wrong target", f => { f.events[2]!.payload.prpEvent.payload.target = "other.txt"; }], ["missing registration", f => { f.run.resultJson.semanticToolReceipts = {} as any; }], ["rejected registration", f => { f.run.resultJson.semanticToolReceipts.publish.result.disposition = "denied"; }], ["wrong registered hash", f => { f.run.resultJson.semanticToolReceipts.publish.input.sha256 = "wrong"; }], @@ -79,6 +130,34 @@ describe("Pi edit, validation and public artifact oracle", () => { ["wrong download bytes", f => { f.downloadedBytes = Buffer.from("wrong\n"); }], ["missing publication activity", f => { f.activity = []; }], ["foreign publication run", f => { f.activity[0]!.runId = "other"; }], + ["conflicting resolved target", f => { f.events[1]!.payload.prpEvent.payload.target = "other.txt"; }], + ["target regresses after resolution", f => { f.events[0]!.payload.prpEvent.payload.target = piFileContract("fixture").filename; f.events[1]!.payload.prpEvent.payload.target = null; }], + ["missing terminal target", f => { f.events[2]!.payload.prpEvent.payload.target = null; }], + ["absent command proof", f => { f.events[4]!.payload.prpEvent.payload.progress = null; }], + ["echo-only command proof", f => { f.events[4]!.payload.prpEvent.payload.progress = "echo PI-VALIDATED-fixture (in_progress): echo PI-VALIDATED-fixture"; }], + ["conflicting command before exact proof", f => { f.events[3]!.payload.prpEvent.payload.progress = "node other.js (pending): node other.js"; }], + ["late command proof", f => { f.events[5]!.payload.prpEvent.payload.progress = f.events[4]!.payload.prpEvent.payload.progress; f.events[4]!.payload.prpEvent.payload.progress = null; }], + ["plain echoed marker output", f => { f.events[5]!.payload.prpEvent.payload.output = piFileContract("fixture").validationMarker; }], + ["nonzero native exit", f => { changeOutput(f, o => { o.structuredContent.exit_code = 1; }); }], + ["missing native exit", f => { changeOutput(f, o => { delete o.structuredContent.exit_code; }); }], + ["truncated native output", f => { changeOutput(f, o => { o.structuredContent.truncated = true; }); }], + ["false marker substring", f => { changeOutput(f, o => { o.structuredContent.output = "failed before PI-VALIDATED-fixture\n"; }); }], + ["contradictory content", f => { changeOutput(f, o => { o.content[0].text = "failed"; }); }], + ["contradictory typed exit", f => { f.events[5]!.payload.prpEvent.payload.exitCode = 1; }], + ["foreign command proof execution", f => { f.events[4]!.payload.prpEvent.payload.executionId = "foreign"; }], + ["foreign command proof turn", f => { f.events[4]!.payload.prpEvent.turnId = "foreign"; }], + ["foreign command proof session", f => { f.events[4]!.payload.prpEvent.normalizedSessionId = "foreign"; }], + ["foreign command proof producer", f => { + const row = f.events[4]!, e = row.payload.prpEvent; + e.sourceInstanceId = row.sourceInstanceId = "foreign"; e.sourceEventId = row.sourceEventId = `foreign:run:${e.sourceSeq}`; + }], + ["durable source differs from envelope", f => { f.events[4]!.sourceInstanceId = "foreign"; }], + ["durable event identity differs from envelope", f => { f.events[4]!.sourceEventId = "runner:run:999"; }], + ["durable source sequence differs from envelope", f => { f.events[4]!.sourceSeq = 999; }], + ["command proof after terminal", f => { + const e = f.events[4]!; f.events.splice(4, 1); e.seq = 7; e.payload.prpEvent.sourceSeq = 7; + e.sourceSeq = 7; e.sourceEventId = e.payload.prpEvent.sourceEventId = "runner:run:7"; f.events.push(e); + }], ]; it.each(mutations)("rejects %s even when the model claims completion", (_name, change) => { const f = fixture(); change(f); expect(() => gradePiFileEvidence(f)).toThrow("Pi file evidence:"); @@ -107,12 +186,25 @@ describe("Pi edit, validation and public artifact oracle", () => { await expect(validate()).rejects.toMatchObject({ code: 1 }); } finally { await rm(root, { recursive: true, force: true }); } }); + it("pastes the literal validation command as Markdown code instead of escaped paragraph text", () => { + const command = piFileContract("0123456789ab-1").validationCommand; + const fenced = piFilePrompt("0123456789ab-1").match(/\n```bash\n([^\n]+)\n```\n/u); + expect(fenced?.[1]).toBe(command); + expect(fenced?.[1]).toContain("!=="); + expect(fenced?.[1]).not.toContain("!\\=\\="); + }); it("changes only Pi file prompts and keeps all four question methods", () => { const cells = runnerMatrix.filter(c => c.suite.id === "extended-harnesses" && c.task.id === "file-edit-validate"); expect(cells).toHaveLength(6); for (const cell of cells) { const prompt = cell.task.buildPrompt("fixture"); expect(prompt.includes("register_deliverable")).toBe(cell.profile.qualificationCandidate === "pi"); + if (cell.profile.qualificationCandidate === "pi") { + const artifact = cell.task.buildMatchers("fixture", cell).find(matcher => matcher.kind === "artifact_exact"); + if (!artifact || artifact.kind !== "artifact_exact") throw new Error("Pi file task must require a registered artifact"); + expect(artifact.name).toBe(piFileContract("fixture").filename); + expect(prompt).toContain(`title ${artifact.name},`); + } } expect(runnerMatrix.filter(c => c.profile.qualificationCandidate === "pi")).toHaveLength(26); }); diff --git a/tests/runner-e2e/pi-file-evidence.ts b/tests/runner-e2e/pi-file-evidence.ts index 1a3e96326e..0dbd7c0283 100644 --- a/tests/runner-e2e/pi-file-evidence.ts +++ b/tests/runner-e2e/pi-file-evidence.ts @@ -21,8 +21,8 @@ export function piFilePrompt(nonce: string) { const c = piFileContract(nonce); return [ `The fixture has already created ${c.filename} in your execution workspace with exactly ${JSON.stringify(c.before)}. Read it, then use your native edit tool once to replace those bytes with exactly ${JSON.stringify(c.after)}. Do not substitute write, bash, or an API for the edit.`, - `Run this exact native bash command, without an added shell prefix or suffix:\n${c.validationCommand}\nIt independently reads the file, fails on incorrect bytes, and prints the validation marker only after success.`, - `Deliver the edited text file as a downloadable attachment using the assigned register_deliverable semantic tool, with filename and workspace-relative contentRef ${c.filename}, contentType text/plain, byteSize ${c.byteSize}, sha256 ${c.sha256}, title Verified text, and a fresh idempotencyKey. Wait for the real registration receipt. Do not upload a different file or claim publication from a disk path.`, + `Run this exact native bash command exactly once, without an added shell prefix or suffix:\n\`\`\`bash\n${c.validationCommand}\n\`\`\`\nIt independently reads the file, fails on incorrect bytes, and prints the validation marker only after success. This is the only bash call allowed for this task; do not run any other shell commands.`, + `Deliver the edited text file as a downloadable attachment using the assigned register_deliverable semantic tool, with filename and workspace-relative contentRef ${c.filename}, contentType text/plain, byteSize ${c.byteSize}, sha256 ${c.sha256}, title ${c.filename}, and a fresh idempotencyKey. Use the supplied byteSize and sha256 for the exact validated post-edit bytes; do not run extra commands to obtain metadata. Wait for the real registration receipt. Do not upload a different file or claim publication from a disk path.`, `After successful validation and registration, call paperclip_finish with reportedWorkDisposition done, summary EXTENDED-FILE-${nonce}, the current completion contract revision and satisfied objective criterion, no remaining work, the actual validation command/result, and the registered artifact reference.`, `Wait for paperclip_finish to succeed, then emit exactly EXTENDED-FILE-${nonce} as your final response. Do not create unrelated files or work.`, ].join("\n"); @@ -58,7 +58,8 @@ export function gradePiCopyback(run: Row, lease: Row, companyId: string, environ } /** Only the common public projection is authority here. Native raw arguments, - * full diff blocks and typed command exit codes are deliberately not invented. */ + * full diff blocks and typed command exit codes are deliberately not invented. + * The native structured output receipt is distinct from that nullable typed field. */ export function gradePiFileEvidence(input: { nonce: string; companyId: string; issueId: string; agentId: string; run: Row; environment: "local" | "daytona"; environmentId: string; lease?: Row; @@ -84,6 +85,7 @@ export function gradePiFileEvidence(input: { && Number.isSafeInteger(row.seq) && row.seq > 0 && !seqs.has(row.seq) && typeof e.sourceInstanceId === "string" && e.sourceInstanceId.length > 0 && Number.isSafeInteger(e.sourceSeq) && e.sourceSeq > (sourceSeqs.get(e.sourceInstanceId) ?? 0) + && row.sourceInstanceId === e.sourceInstanceId && row.sourceSeq === e.sourceSeq && row.sourceEventId === e.sourceEventId && e.sourceEventId === `${e.sourceInstanceId}:${e.runId}:${e.sourceSeq}` && !seen.has(e.sourceEventId), "foreign, duplicated, or reordered durable evidence"); seqs.add(row.seq); seen.add(e.sourceEventId); sourceSeqs.set(e.sourceInstanceId, e.sourceSeq); return { row, e, p: rec(e.payload) }; @@ -104,20 +106,46 @@ export function gradePiFileEvidence(input: { && starts.length === 1 && starts[0]!.row.seq < end.row.seq && matches.filter(x => x.e.eventType === "tool.execution.completed").length === 1 && matches.every(x => x.p.schema === end.p.schema && x.p.name === end.p.name && x.p.operation === end.p.operation - && x.p.target === end.p.target && x.e.sourceKind === "runner" && x.e.turnId === end.e.turnId + && x.row.seq >= starts[0]!.row.seq && x.row.seq <= end.row.seq + && x.e.sourceKind === "runner" && x.e.turnId === end.e.turnId && x.e.normalizedSessionId === end.e.normalizedSessionId && x.e.sourceInstanceId === end.e.sourceInstanceId - && (x === end || x.p.status === "running")), "incomplete or unsuccessful native tool lifecycle"); - return starts[0]!; + && (x === end || (x.p.status === "running" + && ["tool.execution.started", "tool.execution.progressed"].includes(x.e.eventType)))), "incomplete or unsuccessful native tool lifecycle"); + // Native arguments arrive incrementally. An unresolved target is allowed + // only before the first exact target; conflicting or regressed targets fail. + let resolved = false; + for (const x of matches) { + requireEvidence(x.p.target === end.p.target || (!resolved && x.p.target === null), "conflicting native tool target"); + if (x.p.target !== null) resolved = true; + } + return { start: starts[0]!, matches }; } lifecycle(edit); const validations = tools.filter(x => x.e.eventType === "tool.execution.completed" && x.p.operation === "execute" - && x.p.name === c.validationCommand && typeof x.p.output === "string" && x.p.output.includes(c.validationMarker)); + && x.p.name === "bash"); requireEvidence(validations.length === 1, "one observed provider validation execution"); - const validation = validations[0]!, start = lifecycle(validation); + const validation = validations[0]!, { start, matches } = lifecycle(validation); requireEvidence(start.row.seq > edit.row.seq && validation.e.turnId === edit.e.turnId && validation.e.normalizedSessionId === edit.e.normalizedSessionId && validation.e.sourceInstanceId === edit.e.sourceInstanceId && validation.p.outputTruncated === false, "validation must follow the edit in the same native turn"); + // The common lifecycle preserves the opening name (bash). ACPX's resolved + // command is retained as progress, rather than replacing that stable name. + const commandProgress = `${c.validationCommand} (in_progress): ${c.validationCommand}`; + const allowedProgress = new Set([`bash (pending): [terminal] ${validation.p.executionId}`, + `${c.validationCommand} (pending): ${c.validationCommand}`, commandProgress]); + requireEvidence(matches.some(x => x !== validation && x.p.progress === commandProgress) + && matches.every(x => x.p.progress === null || allowedProgress.has(x.p.progress)), + "exact correlated command progress before completion required"); + requireEvidence(typeof validation.p.output === "string" && Buffer.byteLength(validation.p.output) <= 16_384, + "bounded native validation receipt required"); + let output: Row = {}; + try { output = rec(JSON.parse(validation.p.output)); } catch { /* Rejected below; plain marker text is insufficient. */ } + const structured = rec(output.structuredContent), expectedOutput = `${c.validationMarker}\n`; + requireEvidence(structured.exit_code === 0 && structured.truncated === false && structured.output === expectedOutput + && Array.isArray(output.content) && output.content.length === 1 + && rec(output.content[0]).type === "text" && rec(output.content[0]).text === expectedOutput + && (validation.p.exitCode === null || validation.p.exitCode === 0), "successful native structured validation receipt required"); const attachments = input.attachments.map(rec).filter(a => a.id === input.attachmentId); requireEvidence(attachments.length === 1, "one public attachment"); const a = attachments[0]!; @@ -144,11 +172,13 @@ export function gradePiFileEvidence(input: { validationExecutionId: validation.p.executionId, attachmentId: a.id, verification: { kind: "independent_exact_bytes", beforeSha256: sha(c.before), afterSha256: c.sha256, downloadedSha256: sha(input.downloadedBytes), byteSize: c.byteSize, providerExecutionStatus: validation.p.status, - commandTitle: validation.p.name, nativeFileTarget: edit.p.target, + command: c.validationCommand, providerToolName: validation.p.name, commandEvidence: "correlated_native_progress", + exitEvidence: "native_structured_output_receipt", nativeStructuredExitCode: structured.exit_code, + typedExitCode: validation.p.exitCode, nativeFileTarget: edit.p.target, nativeFileAttribution: "workspace_relative_display_target" }, diff: { source: "independent_seed_and_workspace_bytes", path: c.filename, text: `--- ${c.filename}\n+++ ${c.filename}\n@@ -1 +1 @@\n-${c.before.trimEnd()}\n+${c.after.trimEnd()}\n` }, - limits: ["Validation command title is checked exactly; raw arguments and typed exitCode are not projected, so raw invocation/exit code are not attested.", + limits: ["Exact command progress and the native structured output receipt are checked; raw arguments and nullable typed exitCode are not attested by those distinct fields.", "Diff is computed from independently checked workspace bytes; it does not qualify native diff presentation or private invocation metadata."], }; } diff --git a/tests/runner-e2e/pi-native-cases.test.ts b/tests/runner-e2e/pi-native-cases.test.ts index f99a6b7887..03de12ad23 100644 --- a/tests/runner-e2e/pi-native-cases.test.ts +++ b/tests/runner-e2e/pi-native-cases.test.ts @@ -1,10 +1,63 @@ import { describe, expect, it } from "vitest"; -import { gradePiNativeAnswers, hasFailedPiWrite, hasPiCrossRootDenial, piNativeTasks } from "./pi-native-cases.js"; +import { gradePiNativeAnswers, gradePiNativeMemory, hasPiNativeMemoryRead, hasFailedPiWrite, hasPiCrossRootDenial, piNativeMemoryPrompt, piNativeTasks } from "./pi-native-cases.js"; import { runnerMatrix, runnerSuites } from "./catalog.js"; import { buildRunnerE2EProcessEnvironment } from "./harness-env.js"; import { parseRunnerSelectors, selectRunnerExecutions } from "./selectors.js"; describe("Pi native Product qualification", () => { + it("supplies native write arguments with one authoritative content string and its final LF", () => { + const nonce = "0123456789abcdef0123456789abcdef"; + const prompt = piNativeMemoryPrompt(nonce, "/unassigned/denied.txt"); + expect(prompt.split(nonce)).toHaveLength(2); + const encoded = /```json\n(.*?)\n```/s.exec(prompt)![1]!; + const args = JSON.parse(encoded); + expect(Object.keys(args)).toEqual(["path", "content"]); + expect(args.path).toBe("/memory/pi-native.txt"); + expect(args.content).toBe(`${nonce}\n`); + expect(Buffer.byteLength(args.content)).toBe(33); + expect(Buffer.from(args.content).at(-1)).toBe(10); + expect(prompt).toContain("Use native read once, without offset or limit"); + expect(prompt).toContain("Do not retry or work around it"); + }); + + it("requires the current nonce and exactly one LF at every memory readback", () => { + const nonce = "0123456789abcdef0123456789abcdef"; + expect(gradePiNativeMemory(`${nonce}\n`, nonce)).toBe(true); + for (const wrong of [undefined, null, {}, nonce, `${nonce}\n\n`, `${nonce}\r\n`, `${nonce}\\n`, `${nonce}\\u000a`, `${"f".repeat(32)}\n`]) { + expect(gradePiNativeMemory(wrong, nonce)).toBe(false); + } + }); + it("requires a completed native read and rejects a shell shortcut or missing receipt", () => { + const nonce = "0123456789abcdef0123456789abcdef"; + const read = { eventType: "tool.execution.completed", payload: { prpEvent: { payload: { + schema: "paperclip.tool.execution.v1", transport: "builtin", name: "read", operation: "read", status: "completed", executionId: "read-1", target: null, readOnly: true, outputTruncated: false, output: JSON.stringify({ content: [{ type: "text", text: `${nonce}\n` }] }), + } } } }; + expect(hasPiNativeMemoryRead([read], nonce)).toBe(true); + const change = (patch: Record) => ({ ...read, payload: { prpEvent: { payload: { ...read.payload.prpEvent.payload, ...patch } } } }); + expect(hasPiNativeMemoryRead([change({ target: "bootstrap.md", output: JSON.stringify({ content: [{ type: "text", text: "bootstrap instructions" }] }) }), read], nonce)).toBe(true); + for (const rows of [[], [{}], [read, read], [change({ target: undefined })], [change({ status: "failed" })], [change({ transport: "mcp" })], [change({ executionId: "" })], [change({ schema: "untrusted" })], [change({ target: "another-file.txt" })], [change({ output: "malformed" })], [change({ outputTruncated: true })], [change({ readOnly: false })], [change({ output: JSON.stringify({ content: [{ type: "text", text: "unrelated-file" }] }) })], [change({ output: JSON.stringify({ content: [{ type: "text", text: nonce }] }) })], [change({ name: "bash", operation: "execute" })], [read, change({ name: "bash", operation: "execute" })]]) { + expect(hasPiNativeMemoryRead(rows, nonce)).toBe(false); + } + }); + + it("binds the remote memory read to the exact agent and current run", () => { + const nonce = "0123456789abcdef0123456789abcdef"; + const remoteRun = { agentId: "11111111-1111-4111-8111-111111111111", runId: "22222222-2222-4222-8222-222222222222" }; + const target = `.paperclip-runtime/agent-files/${remoteRun.agentId}/${remoteRun.runId}/memory/pi-native.txt`; + const read = (patch: Record = {}) => ({ eventType: "tool.execution.completed", payload: { prpEvent: { payload: { + schema: "paperclip.tool.execution.v1", transport: "builtin", name: "read", operation: "read", status: "completed", executionId: "remote-read-1", target, readOnly: true, outputTruncated: false, output: JSON.stringify({ content: [{ type: "text", text: `${nonce}\n` }] }), ...patch, + } } } }); + expect(hasPiNativeMemoryRead([read()], nonce, remoteRun)).toBe(true); + expect(hasPiNativeMemoryRead([read({ target: "bootstrap.md", output: JSON.stringify({ content: [{ type: "text", text: "bootstrap" }] }) }), read()], nonce, remoteRun)).toBe(true); + expect(hasPiNativeMemoryRead([read()], nonce)).toBe(false); + for (const patch of [{ target: null }, { target: "memory/pi-native.txt" }, { target: `${target}.bak` }, { target: target.replace(remoteRun.agentId, remoteRun.runId) }, { target: target.replace(remoteRun.runId, remoteRun.agentId) }, { output: JSON.stringify({ content: [{ type: "text", text: nonce }] }) }, { status: "failed" }, { outputTruncated: true }]) { + expect(hasPiNativeMemoryRead([read(patch)], nonce, remoteRun)).toBe(false); + } + expect(hasPiNativeMemoryRead([read(), read()], nonce, remoteRun)).toBe(false); + expect(hasPiNativeMemoryRead([read(), read({ name: "bash", operation: "execute" })], nonce, remoteRun)).toBe(false); + expect(hasPiNativeMemoryRead([read()], nonce, { ...remoteRun, runId: "../other-run" })).toBe(false); + }); + it("selects five local and five remote Pi cases without changing the basic extended matrix", () => { const suite = runnerSuites.find(row => row.id === "pi-native")!; expect(suite.manualOnly).toBe(true); expect(suite.expectedMatrixSize).toBe(10); @@ -29,9 +82,9 @@ describe("Pi native Product qualification", () => { it("admits explicit local and remote Pi native candidates and discards ambient admission", () => { const cell = runnerMatrix.find(row => row.suite.id === "pi-native")!; const source = { PAPERCLIP_RUNNER_ACPX_QUALIFICATION: "ambient" }; - expect(JSON.parse(buildRunnerE2EProcessEnvironment(source, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION!)).toEqual([{ agent: "pi", model: cell.profile.model }]); + expect(buildRunnerE2EProcessEnvironment(source, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined(); const remote = runnerMatrix.find(row => row.suite.id === "pi-native" && row.environment.id === "daytona")!; - expect(JSON.parse(buildRunnerE2EProcessEnvironment(source, [remote]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION!)).toEqual([{ agent: "pi", model: remote.profile.model }]); + expect(buildRunnerE2EProcessEnvironment(source, [remote]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined(); expect(buildRunnerE2EProcessEnvironment(source, []).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined(); for (const changed of [ { ...cell, suite: { ...cell.suite, manualOnly: false } }, diff --git a/tests/runner-e2e/pi-native-cases.ts b/tests/runner-e2e/pi-native-cases.ts index a7011f0aaf..a58ac8c543 100644 --- a/tests/runner-e2e/pi-native-cases.ts +++ b/tests/runner-e2e/pi-native-cases.ts @@ -1,6 +1,8 @@ import type { RunnerTaskFixture } from "./types.js"; export const PI_NATIVE_MEMORY_PATH = "memory/pi-native.txt"; +export const PI_NATIVE_MEMORY_PARENT_SEED_PATH = "memory/.pi-e2e-parent.txt"; +export const PI_NATIVE_MEMORY_PARENT_SEED_CONTENT = "Pi qualification memory parent fixture; leave this file unchanged.\n"; export const piNativeTasks: readonly RunnerTaskFixture[] = [ ["native-questions", "Four native questions survive browser reconnect", 1], ["agent-files-fresh-run", "Agent files save and survive a fresh task", 2], @@ -19,7 +21,51 @@ export const piNativeTasks: readonly RunnerTaskFixture[] = [ })); export function piNativeFinish(marker: string): string { - return `After verifying the requested outcome, call paperclip_finish once with reportedWorkDisposition done, summary ${marker}, the current completion contract revision, satisfied objective criterion, no remaining work, evidence [], and verification []. Wait for acceptance, then reply exactly ${marker}. Do not create unrelated work.`; + return `After verifying the requested outcome, call paperclip_finish once with reportedWorkDisposition done, summary ${marker}, the current completion contract revision, satisfied objective criterion with evidenceRefs [], no remaining work, evidence [], and verification []. Wait for acceptance, then reply exactly ${marker}. Do not create unrelated work.`; +} + +/** Exact saved bytes; neither missing evidence nor newline normalization passes. */ +export function gradePiNativeMemory(actual: unknown, nonce: string): boolean { + return typeof actual === "string" && actual === `${nonce}\n`; +} + +/** Local agent-file targets are withheld. Remote per-turn copies are projected + * under the exact agent/run path. Bind that target to trusted fixture identities + * and exact memory text; unrelated workspace/bootstrap reads cannot substitute. */ +export function hasPiNativeMemoryRead(events: readonly Record[], nonce: string, remoteRun?: { agentId: string; runId: string }): boolean { + const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/u; + if (remoteRun && (!uuid.test(remoteRun.agentId) || !uuid.test(remoteRun.runId))) return false; + const target = remoteRun ? `.paperclip-runtime/agent-files/${remoteRun.agentId}/${remoteRun.runId}/${PI_NATIVE_MEMORY_PATH}` : null; + const tools = events.filter(row => row.eventType === "tool.execution.completed") + .map(row => row.payload?.prpEvent?.payload) + .filter(payload => payload?.schema === "paperclip.tool.execution.v1" && payload.transport === "builtin"); + const memoryReads = tools.filter(payload => { + if (payload.name !== "read" || payload.operation !== "read" || payload.status !== "completed" + || payload.target !== target || payload.readOnly !== true || payload.outputTruncated !== false + || typeof payload.executionId !== "string" || payload.executionId.length === 0 || typeof payload.output !== "string") return false; + try { + const result = JSON.parse(payload.output); + return Array.isArray(result.content) && result.content.length === 1 + && result.content[0]?.type === "text" && gradePiNativeMemory(result.content[0].text, nonce); + } catch { return false; } + }); + return !tools.some(payload => payload.operation === "execute") && memoryReads.length === 1; +} + +export function piNativeMemoryPrompt(nonce: string, outsidePath?: string): string { + const writeArguments = JSON.stringify({ path: `/${PI_NATIVE_MEMORY_PATH}`, content: `${nonce}\n` }); + return [ + "Save this personal memory using native write once. Replace with the canonical absolute AGENT_HOME directory from this turn's runtime instructions:", + "```json", + writeArguments, + "```", + `The file must contain exactly ${Buffer.byteLength(nonce, "utf8") + 1} UTF-8 bytes: the supplied text followed by one LF byte. The JSON \\n denotes that LF, not literal backslash-and-n characters. Native write does not add it for you.`, + "Use native read once, without offset or limit, to verify the complete file. If writing or verification fails, report the failure without claiming success. Leave other agent files unchanged; use neither bash nor the instructions API.", + ...(outsidePath === undefined ? [] : [ + `Then attempt native write once to ${JSON.stringify(outsidePath)} with content forbidden. This unassigned path must be denied; that denial is expected. Do not retry or work around it.`, + ]), + piNativeFinish("PI-NATIVE-MEMORY-STAGED"), + ].join("\n"); } export function piNativePrompt(id: string, nonce: string): string { @@ -42,12 +88,7 @@ export function piNativePrompt(id: string, nonce: string): string { "Keep the actual four tool result objects in order. Using the native write tool, write their JSON array to pi-native-answers.json in the task workspace. This is an internal verification file, not a deliverable. Do not infer, change, or prefill any answer.", piNativeFinish(`PI-NATIVE-${id}-${nonce}`), ].join("\n"); - if (id === "agent-files-fresh-run") return [ - "Find the exact registered AGENT_HOME working-copy path in your runtime instructions. Use native write/read file tools for this task, not bash or the instructions API.", - `Write exactly ${JSON.stringify(`${nonce}\n`)} to ${PI_NATIVE_MEMORY_PATH} inside that AGENT_HOME and read it back. This is personal memory, not a task deliverable. Do not edit AGENTS.md or a project file instead.`, - "Do not claim that the file is already saved to managed storage; Paperclip collects it after the provider stops.", - piNativeFinish("PI-NATIVE-MEMORY-STAGED"), - ].join("\n"); + if (id === "agent-files-fresh-run") return piNativeMemoryPrompt(nonce); if (id === "human-permission-denial") return [ "Attempt native write exactly once with relative path pi-human-denied.txt and content forbidden. Wait for the actual browser permission decision. Do not retry, use bash or an API, write another path, or work around denial.", "After the native write returns its denial, finish this negative test. Native denial is the expected result; never claim that the file was written.", diff --git a/tests/runner-e2e/pi-native-evidence.test.ts b/tests/runner-e2e/pi-native-evidence.test.ts index 6f165b84c9..836763e132 100644 --- a/tests/runner-e2e/pi-native-evidence.test.ts +++ b/tests/runner-e2e/pi-native-evidence.test.ts @@ -10,7 +10,12 @@ import { sanitizeJson } from "./redaction.js"; // Drive the actual flow and packager without a browser, provider, or server. // Only the external browser/API boundaries are scripted. -vi.mock("./user-actions.js", () => ({ createTaskThroughUi: vi.fn() })); +vi.mock("./user-actions.js", () => ({ + createTaskThroughUi: vi.fn(async (input: { requireExplicitTitle?: boolean }) => { + expect(input.requireExplicitTitle).toBe(true); + return { submittedAtMs: Date.now(), issueId: "issue-fixture" }; + }), +})); vi.mock("@playwright/test", () => ({ expect: (actual: unknown, message?: string) => ({ toBe: (expected: unknown) => expect(actual, message).toBe(expected), @@ -28,7 +33,7 @@ async function fixture(options: { failComments?: boolean; remote?: boolean; inco const workspacePath = join(root, "workspace"); const snapshots = join(privateDir, "snapshots"); for (const directory of [workspacePath, snapshots, join(privateDir, "html-report"), join(privateDir, "blob-report")]) await mkdir(directory, { recursive: true }); const execution = { id: "pi-native.runner-acpx-pi.local.native-questions", environment: { id: options.remote ? "daytona" : "local" }, profile: { qualificationCandidate: "pi" }, task: piNativeTasks.find(row => row.id === "native-questions")! } as FlowInput["execution"]; - const issue = { id: "issue-fixture", identifier: "PI-1", title: execution.task.buildTitle("fixture"), status: "done" }; + const issue = { id: "issue-fixture", identifier: "PI-1", title: "Provider-generated task name", companyId: "company-fixture", assigneeAgentId: "agent-fixture", status: "done" }; const run = { id: "run-fixture", status: "succeeded", runtimeMode: "native", createdAt: "2026-09-29T00:00:00Z" }; const headers = ["Pi native color", "Pi native confirmation", "Pi native name", "Pi native draft"]; let answered = 0; const typed: string[] = []; let answerProof = ""; let remoteFinished = false; const cleanupAssertions: Array<() => Promise> = []; @@ -88,7 +93,7 @@ async function fixture(options: { failComments?: boolean; remote?: boolean; inco }; const input = { page: page as unknown as FlowInput["page"], api: api as unknown as FlowInput["api"], - fixtures: { company: { id: "company-fixture", issuePrefix: "PI" }, agent: { name: "Pi fixture" }, environment: { id: "environment-fixture" } } as FlowInput["fixtures"], + fixtures: { company: { id: "company-fixture", issuePrefix: "PI" }, agent: { id: "agent-fixture", name: "Pi fixture" }, environment: { id: "environment-fixture" } } as FlowInput["fixtures"], execution, nonce: "fixture", workspacePath, deadlineAt: Date.now() + 5000, restart: async () => { throw new Error("Unexpected restart"); }, observe: () => {}, capture, evidence, ...(options.remote ? { registerCleanupAssertion: (fn: () => Promise) => cleanupAssertions.push(fn), remoteBootstrap: { @@ -108,7 +113,7 @@ describe("Pi native terminal evidence", () => { it("packages the actual successful native-question flow with durable API state and every event page", async () => { const f = await fixture(); const result = await runPiNativeFlow(f.input); - expect(result.checks).toHaveLength(15); + expect(result.checks).toHaveLength(16); expect(result.checks.every(check => check.passed)).toBe(true); const packaged = await packageEvidence({ privateDir: f.privateDir, uploadDir: f.uploadDir, secrets: [f.secret], expectPassScreenshot: true }); expect(packaged.missing).toEqual([]); expect(packaged.leaks).toEqual([]); @@ -134,7 +139,7 @@ describe("Pi native terminal evidence", () => { const f = await fixture({ failComments: true }); await expect(runPiNativeFlow(f.input)).rejects.toThrow("fixture comments unavailable"); const checks = JSON.parse(await readFile(join(f.snapshots, "pi-native-checks.json"), "utf8")); - expect(checks.checks).toHaveLength(15); + expect(checks.checks).toHaveLength(16); expect(checks.checks.every((check: { passed: boolean }) => check.passed)).toBe(true); expect(f.capture.mock.calls.some(([id]) => id === "final-state")).toBe(false); const packaged = await packageEvidence({ privateDir: f.privateDir, uploadDir: f.uploadDir, secrets: [f.secret], expectPassScreenshot: true }); diff --git a/tests/runner-e2e/pi-native-flow.ts b/tests/runner-e2e/pi-native-flow.ts index 2b771161cd..7ff3d95ec8 100644 --- a/tests/runner-e2e/pi-native-flow.ts +++ b/tests/runner-e2e/pi-native-flow.ts @@ -1,3 +1,4 @@ +import type { RestartRunnerIdentity } from "./process-tree-owner.js"; import { observeRunProcesses, createDeniedTargetFixture, bindDeniedTargetPrompt } from "./copilot-local-fixtures.js"; import { hasDeliveredPiDenial, piPermissionRequests, hasPiRemoteRetirement, hasUnchangedPiRemoteTarget } from "./pi-native-evidence.js"; import { randomBytes } from "node:crypto"; @@ -7,11 +8,12 @@ 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 { gradePiNativeAnswers, hasFailedPiWrite, hasPiCrossRootDenial, PI_NATIVE_MEMORY_PATH, piNativeFinish } from "./pi-native-cases.js"; +import { gradePiNativeAnswers, gradePiNativeMemory, hasPiNativeMemoryRead, hasFailedPiWrite, hasPiCrossRootDenial, PI_NATIVE_MEMORY_PATH, PI_NATIVE_MEMORY_PARENT_SEED_PATH, PI_NATIVE_MEMORY_PARENT_SEED_CONTENT, piNativeFinish, piNativeMemoryPrompt } from "./pi-native-cases.js"; import type { LiveFixtureValues } from "./live-fixtures.js"; import type { MatrixExecution } from "./types.js"; -import type { RemoteNativeFixture, RemoteNativeSnapshot } from "./remote-native-fixtures.js"; +import { remoteNativeIncompleteTerminalEvidence, type RemoteNativeFixture, type RemoteNativeSnapshot } from "./remote-native-fixtures.js"; +import { approvePiBootstrapRead, withoutApprovedPiBootstrapRequests, type PiBootstrapApproval } from "./pi-bootstrap-permission.js"; import { runPiPendingProviderDeath, PI_DEATH_MARKER } from "./pi-native-provider-death-flow.js"; import { runPiPendingControllerRestart } from "./pi-native-restart-flow.js"; @@ -25,7 +27,7 @@ type Check = { id: string; passed: boolean; detail: string }; export async function runPiNativeFlow(input: { page: Page; api: RunnerApi; fixtures: LiveFixtureValues; execution: MatrixExecution; nonce: string; - workspacePath: string; deadlineAt: number; restart(): Promise; + workspacePath: string; deadlineAt: number; restart(preserveRunner?: RestartRunnerIdentity): Promise; observe(issue: Row, runs: Row[]): void; capture(id: string, label: string, file: string): Promise; evidence(name: string, data: unknown): Promise; @@ -37,11 +39,26 @@ export async function runPiNativeFlow(input: { if (!["local", "daytona"].includes(execution.environment.id) || execution.profile.qualificationCandidate !== "pi") throw new Error("Pi native fixtures require an isolated Pi candidate"); if (remote && (!input.remoteBootstrap || !input.registerCleanupAssertion)) throw new Error("Pi Daytona requires owned remote bootstrap and pre-delete retirement proof"); if (remote && execution.task.id === "restrictive-denial") throw new Error("Pi deny-all cannot read the native action-file bootstrap; remote auto-denial remains unsupported"); + let bootstrapApproval: PiBootstrapApproval | undefined; let currentRemote: RemoteNativeFixture | undefined, currentBaseline: RemoteNativeSnapshot | undefined; let remoteSequence = 0; + const retainedIncompleteTerminals = new Set(); + async function retainIncompleteTerminal(fixture: RemoteNativeFixture, ordinal: number, error: unknown) { + const snapshot = remoteNativeIncompleteTerminalEvidence(error); + if (!snapshot || retainedIncompleteTerminals.has(ordinal)) return; + try { + await input.evidence(`pi-remote-${ordinal}-incomplete-terminal.json`, { binding: fixture.binding, snapshot, passed: false }); + retainedIncompleteTerminals.add(ordinal); + } catch { + // The caller must rethrow the original qualification error. Leave this + // ordinal unretained so cleanup can still try to save its diagnostic. + } + } async function finishRemote(label: string) { if (!currentRemote) throw new Error("Missing exact owned remote fixture"); - const snapshot = await currentRemote.finish(); + let snapshot: RemoteNativeSnapshot; + try { snapshot = await currentRemote.finish(); } + catch (error) { await retainIncompleteTerminal(currentRemote, remoteSequence, error); throw error; } await input.evidence(`pi-remote-${remoteSequence}-${label}.json`, { binding: currentRemote.binding, baseline: currentBaseline, snapshot }); if (!hasPiRemoteRetirement(snapshot)) throw new Error("Pi remote provider retirement is unproven; sandbox deletion is not proof"); return snapshot; @@ -69,9 +86,10 @@ export async function runPiNativeFlow(input: { async function create(title: string, prompt: string | ((fixture: RemoteNativeFixture) => string), options: { targets?: string[]; crossRoot?: { initialText: string } } = {}) { const previous = new Set(runs.map(run => run.id)); const actualPrompt = remote ? input.remoteBootstrap!.prompt(`${nonce}-${++remoteSequence}`) : typeof prompt === "string" ? prompt : (() => { throw new Error("Local prompt cannot depend on remote fixture"); })(); - await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title, prompt: actualPrompt, workMode: "standard", projectName: project.name }); - const found = await pollUntil({ label: title, deadlineAt: input.deadlineAt, load: async () => (await api.get(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.title === title), accept: Boolean }); - if (!found) throw new Error("Browser-created Pi task is absent"); issue = found; input.observe(issue, runs); + const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title, prompt: actualPrompt, workMode: "standard", projectName: project.name, requireExplicitTitle: true }); + issue = await api.get(`/api/issues/${createdTask.issueId}`); + check("created-task-identity", issue.id === createdTask.issueId && issue.companyId === fixtures.company.id && issue.assigneeAgentId === fixtures.agent.id, "Creation response binds the exact company-scoped assigned task before native action"); + input.observe(issue, runs); await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`); if (remote) { const state = await pollUntil({ label: "Pi remote bootstrap native run", deadlineAt: input.deadlineAt, load, reject: rejectFailure, accept: value => value.runs.filter(run => !previous.has(run.id)).length === 1 }); @@ -81,6 +99,7 @@ export async function runPiNativeFlow(input: { const fixture = currentRemote, ordinal = remoteSequence; input.registerCleanupAssertion!(async () => { try { const snapshot = await fixture.finish(); const passed = hasPiRemoteRetirement(snapshot); await input.evidence(`pi-remote-${ordinal}-retirement.json`, { binding: fixture.binding, snapshot, passed }); if (!passed) throw new Error("Pi remote retirement proof incomplete"); return [{ id: `remote-retirement-${ordinal}`, passed, detail: "Independent remote observer sealed exact run retirement before public lease deletion" }]; } + catch (error) { await retainIncompleteTerminal(fixture, ordinal, error); throw error; } finally { await fixture.close(); } }); } @@ -112,7 +131,7 @@ export async function runPiNativeFlow(input: { await create(execution.task.buildTitle(nonce), execution.task.buildPrompt(nonce), { targets: ["pi-native-restart-answer.json"] }); checks.push(...await runPiPendingControllerRestart({ page, companyId: fixtures.company.id, deadlineAt: input.deadlineAt, load, events, - restart: input.restart, settle: () => settle(1), capture: input.capture, evidence: input.evidence, + preserveLocalRunner: !remote, restart: input.restart, settle: () => settle(1), capture: input.capture, evidence: input.evidence, readProof: async () => { if (remote) await finishRemote("native-restart-final"); return JSON.parse(await readWorkspace("pi-native-restart-answer.json")); @@ -149,29 +168,42 @@ export async function runPiNativeFlow(input: { await input.evidence("pi-native-typed-proof.json", { proof, interactions: final.interactions, events: await events(runs[0]!.id) }); } else if (execution.task.id === "agent-files-fresh-run") { const retained = randomBytes(16).toString("hex"); const outside = resolve(input.workspacePath, "..", `pi-unassigned-${nonce}.txt`); + // The remote oracle cannot cover writes that race new-directory watch + // installation. Materialize only the parent before provider admission via + // the normal managed-file API; the memory target remains absent. + const seed = await api.request.put(`/api/agents/${fixtures.agent.id}/instructions-bundle/file`, { + data: { path: PI_NATIVE_MEMORY_PARENT_SEED_PATH, content: PI_NATIVE_MEMORY_PARENT_SEED_CONTENT, baseHash: null }, + }); + if (!seed.ok()) throw new Error(`Pi memory parent setup failed (${seed.status()})`); + check("memory-parent-seeded", (await seed.json()).content === PI_NATIVE_MEMORY_PARENT_SEED_CONTENT, "Public managed-file setup created the watched memory parent before task admission"); if (!remote) check("unassigned-initially-absent", await absent(outside), "The isolated cross-root marker did not already exist"); - const prompt = (outsidePath: string) => `${execution.task.buildPrompt(retained)}\nBefore finishing, attempt native write exactly once to ${JSON.stringify(outsidePath)} with content forbidden. This intentionally unassigned root must be denied. Never use bash or another operation to work around its failure.`; + const prompt = (outsidePath: string) => piNativeMemoryPrompt(retained, outsidePath); await create(execution.task.buildTitle(nonce), remote ? fixture => { if (!fixture.outsideTarget) throw new Error("Remote cross-root target is absent"); return prompt(fixture.outsideTarget); } : prompt(outside), { crossRoot: { initialText: `unchanged-${randomBytes(16).toString("hex")}` } }); await settle(1); const personal = await api.get(`/api/agents/${fixtures.agent.id}/instructions-bundle/file?path=${encodeURIComponent(PI_NATIVE_MEMORY_PATH)}`); const firstEvents = await events(runs[0]!.id); - check("registered-file-saved", personal.content === `${retained}\n`, "Public managed-file API contains the exact native-write bytes"); + await input.evidence("pi-agent-files-first-save.json", { personal, run: runs[0], events: firstEvents }); + check("native-memory-write-verification", hasPiNativeMemoryRead(firstEvents, retained, remote ? { agentId: fixtures.agent.id, runId: runs[0]!.id } : undefined), "One completed native read verified the write, with no shell execution"); + check("registered-file-saved", gradePiNativeMemory(personal.content, retained), "Public managed-file API contains the exact native-write bytes"); check("stopped-save-receipt", firstEvents.some(row => row.eventType === "instruction_save" && row.payload?.state === "saved"), "Provider stop produced a durable file-save receipt"); const crossRootIntact = remote ? hasUnchangedPiRemoteTarget(currentBaseline, await finishRemote("cross-root-final"), "@cross-root") : await absent(outside); check("cross-root-denied", crossRootIntact && hasPiCrossRootDenial(firstEvents), "A single native write recorded the exact cross-root denial reason and the isolated target remains unchanged"); - await input.evidence("pi-agent-files-first-save.json", { personal, run: runs[0], events: firstEvents }); await input.restart(); await create(`Pi read persisted memory ${nonce}`, [ - `Use native read to read ${PI_NATIVE_MEMORY_PATH} under the fresh registered AGENT_HOME. Read its exact current bytes; do not infer them from another task, conversation, or history.`, - "Use native write to copy those exact bytes into pi-agent-memory-proof.txt in the task workspace. This is an internal assertion file, not a deliverable. Do not change personal memory.", + `Use native read once with path /${PI_NATIVE_MEMORY_PATH}. Replace with the canonical absolute directory from this turn\'s runtime instructions. Read the complete file without offset or limit; do not infer its contents from another task, conversation, or history.`, + "Use native write to copy those exact bytes into pi-agent-memory-proof.txt in the task workspace. This is an internal assertion file, not a deliverable. Do not change personal memory or other agent files. Do not use bash or the instructions API.", piNativeFinish(execution.task.buildVisibleMarker(nonce)), ].join("\n"), { targets: ["pi-agent-memory-proof.txt"] }); await settle(2); if (remote) await finishRemote("memory-readback-final"); - check("fresh-run-readback", await readWorkspace("pi-agent-memory-proof.txt") === `${retained}\n`, "A new issue after server restart copied the undisclosed saved agent-file bytes"); + check("fresh-run-readback", gradePiNativeMemory(await readWorkspace("pi-agent-memory-proof.txt"), retained), "A new issue after server restart copied the undisclosed saved agent-file bytes"); const current = await api.get(`/api/agents/${fixtures.agent.id}/instructions-bundle/file?path=${encodeURIComponent(PI_NATIVE_MEMORY_PATH)}`); check("persistent-bytes-unchanged", current.content === personal.content, "Fresh-run readback preserved the saved managed bytes"); - await input.evidence("pi-agent-files-fresh-read.json", { current, runs, events: await events(runs[1]!.id) }); + const parentSeed = await api.get(`/api/agents/${fixtures.agent.id}/instructions-bundle/file?path=${encodeURIComponent(PI_NATIVE_MEMORY_PARENT_SEED_PATH)}`); + check("memory-parent-seed-unchanged", parentSeed.content === PI_NATIVE_MEMORY_PARENT_SEED_CONTENT, "Both native turns preserved the parent setup file"); + const freshEvents = await events(runs[1]!.id); + await input.evidence("pi-agent-files-fresh-read.json", { current, runs, events: freshEvents }); + check("native-memory-fresh-read", hasPiNativeMemoryRead(freshEvents, retained, remote ? { agentId: fixtures.agent.id, runId: runs[1]!.id } : undefined), "The fresh run used one completed native read, with no shell execution"); } else if (execution.task.id === "human-permission-denial") { if (!input.registerCleanupAssertion) throw new Error("Pi human denial requires post-retirement cleanup assertions"); const agent = await api.get(`/api/agents/${fixtures.agent.id}`); @@ -201,20 +233,27 @@ export async function runPiNativeFlow(input: { }); if (!remote) check("human-target-initially-absent", await absent(path), "Independent target is absent before provider work"); await create(execution.task.buildTitle(nonce), localTarget ? bindDeniedTargetPrompt(execution.task.buildPrompt(nonce), "pi-human-denied.txt", target) : execution.task.buildPrompt(nonce), { targets: [target] }); + if (remote) bootstrapApproval = await approvePiBootstrapRead({ api, fixture: currentRemote!, companyId: fixtures.company.id, issueId: issue.id, runId: runs[0]!.id, + deadlineAt: input.deadlineAt, load: async () => { await load(); return { run: runs[0]!, issue, events: await events(runs[0]!.id) }; }, evidence: input.evidence }); if (remote) check("human-target-initially-absent", currentBaseline?.targets[target]?.absent === true && currentBaseline.targets[target]!.complete, "Remote watcher was armed before action publication with an absent target"); const pending = await pollUntil({ label: "Pi native browser permission", deadlineAt: input.deadlineAt, load: async () => { const state = await load(); processes = observe(); return { ...state, events: state.runs.length === 1 ? await events(state.runs[0]!.id) : [] }; }, reject: rejectFailure, - accept: state => state.runs.length === 1 && piPermissionRequests(state.events, state.runs[0]!.id).length === 1 }); - const native = piPermissionRequests(pending.events, pending.runs[0]!.id)[0]!; + accept: state => state.runs.length === 1 && piPermissionRequests(withoutApprovedPiBootstrapRequests(state.events, bootstrapApproval), state.runs[0]!.id).length === 1 }); + const native = piPermissionRequests(withoutApprovedPiBootstrapRequests(pending.events, bootstrapApproval), pending.runs[0]!.id)[0]!; const identity = { runId: pending.runs[0]!.id, turnId: native.event.turnId, requestId: native.request.requestId, toolCallId: native.request.details.toolCallId, target }; check("human-target-pending-absent", remote ? (await currentRemote!.snapshot("permission-pending")).targets[target]?.absent === true : await absent(path), "Pending native write has no file effect"); await page.reload(); const before = await events(identity.runId); check("human-permission-reconnect", piPermissionRequests(before, identity.runId).some(value => value.request.requestId === identity.requestId) && !before.some(row => row.payload?.prpEvent?.payload?.requestId === identity.requestId && ["runtime_request.resolved", "runtime_request.expired", "runtime_request.cancelled"].includes(row.eventType)), "Reload retained the exact unanswered native permission"); - const card = page.getByTestId("task-chat-runtime-request").filter({ visible: true }); await expect(card).toHaveCount(1); + const declineLabel = native.request.choices.find((choice: Row) => choice.key === "decline").label; + // Resolved setup-read receipts remain visible but have no decision + // buttons. Require one actionable card and verify its exact POST below. + const card = page.getByTestId("task-chat-runtime-request").filter({ visible: true }) + .filter({ has: page.getByRole("button", { name: declineLabel, exact: true }) }); + await expect(card).toHaveCount(1); await input.capture("pi-human-denial", "Pi native permission awaiting browser denial", "pi-human-denial.png"); const route = `/api/heartbeat-runs/${identity.runId}/runtime-requests/${encodeURIComponent(identity.requestId)}/resolve`; const posted = page.waitForRequest(request => new URL(request.url()).pathname === route && request.method() === "POST"); - await card.getByRole("button", { name: native.request.choices.find((choice: Row) => choice.key === "decline").label, exact: true }).click(); + await card.getByRole("button", { name: declineLabel, exact: true }).click(); const response = (await posted).postDataJSON(); check("human-exact-browser-decline", response.turnId === identity.turnId && response.requestKind === "permission_approval" && response.resolution?.action === "decline", "Actual browser POST declines this run, turn and request"); const final = await settle(1), runEvents = await events(identity.runId); diff --git a/tests/runner-e2e/pi-native-human-flow.test.ts b/tests/runner-e2e/pi-native-human-flow.test.ts index 3d6f1c24fe..a6b452b69a 100644 --- a/tests/runner-e2e/pi-native-human-flow.test.ts +++ b/tests/runner-e2e/pi-native-human-flow.test.ts @@ -4,8 +4,10 @@ import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { piNativeTasks } from "./pi-native-cases.js"; import { runPiNativeFlow } from "./pi-native-flow.js"; +vi.mock("./pi-bootstrap-permission.js", async importOriginal => ({ ...await importOriginal(), approvePiBootstrapRead: async () => undefined })); + const proof = vi.hoisted(() => ({ mutation: false, incomplete: false, live: false, target: "pi-human-denied.txt", prompt: "" })); -vi.mock("./user-actions.js", () => ({ createTaskThroughUi: vi.fn(async (input: { prompt: string }) => { proof.prompt = input.prompt; }) })); +vi.mock("./user-actions.js", () => ({ createTaskThroughUi: vi.fn(async (input: { prompt: string; requireExplicitTitle?: boolean }) => { expect(input.requireExplicitTitle).toBe(true); proof.prompt = input.prompt; return { submittedAtMs: Date.now(), issueId: "issue" }; }) })); vi.mock("./copilot-local-fixtures.js", async importOriginal => { const actual = await importOriginal(); return { ...actual, @@ -19,12 +21,12 @@ vi.mock("@playwright/test", () => ({ expect: (actual: any, message?: string) => toBe: (value: unknown) => expect(actual, message).toBe(value), toBeVisible: async () => {}, toHaveCount: async (value: number) => expect(actual.count).toBe(value), }) })); const wrap = (eventType: string, seq: number, payload: unknown) => ({ runId: "run", protocolSchemaVersion: 1, eventType, seq, payload: { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", runId: "run", turnId: "turn", eventType, payload } } }); -async function exercise(mutation: boolean, remote = false, adapter = "acpx-runtime", incomplete = false) { +async function exercise(mutation: boolean, remote = false, adapter = "acpx-runtime", incomplete = false, duplicatePermission = false) { proof.mutation = mutation; proof.incomplete = incomplete; proof.live = false; proof.target = "pi-human-denied.txt"; proof.prompt = ""; const workspacePath = await mkdtemp(join(tmpdir(), "pi-human-fixture-")); let declined = false, browserPosts = 0; const saved = new Map(); const cleanup: Array<() => Promise> = []; const task = piNativeTasks.find(row => row.id === "human-permission-denial")!; - const issue = () => ({ id: "issue", identifier: "PI-1", title: task.buildTitle("fixture"), status: declined ? "done" : "in_progress" }); + const issue = () => ({ id: "issue", identifier: "PI-1", title: "Provider-generated task name", companyId: "company", assigneeAgentId: "agent", status: declined ? "done" : "in_progress" }); const run = () => ({ id: "run", status: declined ? "succeeded" : "running", runtimeMode: "native", processPid: 123, processGroupId: 123, processStartedAt: "2026-09-29T00:00:00Z" }); const request = { requestId: "request", turnId: "turn", type: "permission", status: "pending", details: { toolCallId: "pi-tool-1" }, origin: { adapter, provider: "pi", method: "session/request_permission" }, choices: [{ key: "decline", label: "Decline" }] }; const events = () => [wrap("runtime_request.created", 1, { request }), ...(declined ? [wrap("runtime_request.resolved", 2, { requestId: "request", turnId: "turn", action: "decline" }), wrap("tool.execution.completed", 3, { schema: "paperclip.tool.execution.v1", transport: "builtin", operation: "edit", executionId: "pi-tool-1", name: "write", target: proof.target, status: "failed", output: "Pi operation was denied or cancelled" })] : [])]; @@ -43,15 +45,27 @@ async function exercise(mutation: boolean, remote = false, adapter = "acpx-runti }; const browserRequest = { url: () => "http://fixture/api/heartbeat-runs/run/runtime-requests/request/resolve", method: () => "POST", postDataJSON: () => ({ turnId: "turn", requestKind: "permission_approval", resolution: { action: "decline" } }) }; let resolvePost: ((value: typeof browserRequest) => void) | undefined; + const card = (actionable = false): any => ({ + filter: (options: { has?: { name: string } }) => { + if (options.has) expect(options.has.name).toBe("Decline"); + return card(actionable || Boolean(options.has)); + }, + // The resolved bootstrap receipt is visible but cannot be declined again. + count: actionable ? (duplicatePermission ? 2 : 1) : (remote ? 2 : 1), + getByRole: (_role: string, options: { name: string }) => ({ click: async () => { expect(options.name).toBe("Decline"); browserPosts++; declined = true; resolvePost!(browserRequest); } }), + }); const page = { goto: async () => {}, reload: async () => {}, waitForRequest: (predicate: (v: typeof browserRequest) => boolean) => new Promise(resolve => { expect(predicate(browserRequest)).toBe(true); resolvePost = resolve; }), - getByTestId: (id: string) => id === "issue-detail-header" ? { getByRole: () => ({}) } : { filter: () => ({ count: 1, getByRole: (_role: string, options: { name: string }) => ({ click: async () => { expect(options.name).toBe("Decline"); browserPosts++; declined = true; resolvePost!(browserRequest); } }) }) } }; + getByRole: (_role: string, options: { name: string }) => ({ name: options.name }), + getByTestId: (id: string) => id === "issue-detail-header" ? { getByRole: () => ({}) } : card() }; const nativeRoot = { pid: 456, ppid: 1, startTicks: "123", bootId: "remote-boot" }; const snapshot = () => ({ observedAtMs: declined ? 2 : 1, complete: true, workspace: {}, targets: { "pi-human-denied.txt": { absent: true, sha256: null, parent: { dev: "1", ino: "2" }, mutationCount: mutation ? 1 : 0, complete: true } }, watcher: { complete: true, targetMutationCount: mutation ? 1 : 0, workspaceMutationCount: 0 }, processes: { captured: true, root: nativeRoot, journal: [nativeRoot], live: declined ? [] : [456] } }); const fixture = { binding: { runId: "run" }, remoteCwd: "/remote", outsideTarget: null, snapshot: async () => snapshot(), finish: async () => snapshot(), close: async () => {} }; const remoteBootstrap = remote ? { prompt: () => "Read bootstrap only", bindAndRelease: async (input: any) => { expect(input.targets).toEqual(["pi-human-denied.txt"]); expect(await input.actionPrompt(fixture)).toContain("native write exactly once"); return fixture; } } : undefined; try { if (remote) await writeFile(join(workspacePath, "pi-human-denied.txt"), "POISON HOST COPYBACK"); - const result = await runPiNativeFlow({ page, api, fixtures: { company: { id: "company", issuePrefix: "PI" }, agent: { id: "agent", name: "Pi" }, environment: { id: "environment" } }, execution: { task, environment: { id: remote ? "daytona" : "local" }, profile: { qualificationCandidate: "pi" } }, workspacePath, nonce: "fixture", deadlineAt: Date.now() + 2000, restart: async () => {}, observe: () => {}, capture: async () => {}, evidence: async (name: string, value: unknown) => { saved.set(name, value); }, remoteBootstrap, registerCleanupAssertion: (fn: () => Promise) => cleanup.push(fn) } as any); + const call = runPiNativeFlow({ page, api, fixtures: { company: { id: "company", issuePrefix: "PI" }, agent: { id: "agent", name: "Pi" }, environment: { id: "environment" } }, execution: { task, environment: { id: remote ? "daytona" : "local" }, profile: { qualificationCandidate: "pi" } }, workspacePath, nonce: "fixture", deadlineAt: Date.now() + 2000, restart: async () => {}, observe: () => {}, capture: async () => {}, evidence: async (name: string, value: unknown) => { saved.set(name, value); }, remoteBootstrap, registerCleanupAssertion: (fn: () => Promise) => cleanup.push(fn) } as any); + if (duplicatePermission) { await expect(call).rejects.toThrow(); expect(browserPosts).toBe(0); return; } + const result = await call; if (!remote) { expect(proof.target).toMatch(/^pc-denied-[a-zA-Z0-9]+\/pi-human-denied\.txt$/); expect(proof.prompt).toContain(`relative path ${proof.target} and content forbidden`); } expect(browserPosts).toBe(1); expect(result.checks.every(check => check.passed)).toBe(true); expect(saved.has("api-state.json")).toBe(true); expect(cleanup).toHaveLength(1); @@ -68,3 +82,4 @@ it("proves browser denial against remote watcher and retirement without trusting it.each([false, true])("drives sidecar permission denial with remote=%s", async remote => exercise(false, remote, "acpx-runtime-sidecar")); it("rejects incomplete local observation even with zero target mutations", async () => exercise(false, false, "acpx-runtime", true)); +it("refuses browser denial when two actionable cards remain beside resolved setup history", async () => exercise(false, true, "acpx-runtime", false, true)); diff --git a/tests/runner-e2e/pi-native-remote-flow.test.ts b/tests/runner-e2e/pi-native-remote-flow.test.ts index d824f99d26..6eeb401e64 100644 --- a/tests/runner-e2e/pi-native-remote-flow.test.ts +++ b/tests/runner-e2e/pi-native-remote-flow.test.ts @@ -3,51 +3,104 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { expect, it, vi } from "vitest"; -import { piNativeTasks } from "./pi-native-cases.js"; +import { piNativeTasks, PI_NATIVE_MEMORY_PARENT_SEED_PATH, PI_NATIVE_MEMORY_PARENT_SEED_CONTENT } from "./pi-native-cases.js"; import { runPiNativeFlow } from "./pi-native-flow.js"; -const browser = vi.hoisted(() => ({ create: (_value: any) => {} })); +import * as remoteFixtures from "./remote-native-fixtures.js"; +const browser = vi.hoisted(() => ({ create: (_value: any) => ({ submittedAtMs: Date.now(), issueId: "issue-fixture" }) })); vi.mock("./user-actions.js", () => ({ createTaskThroughUi: async (value: unknown) => browser.create(value) })); vi.mock("@playwright/test", () => ({ expect: (value: unknown, message?: string) => ({ toBe: (expected: unknown) => expect(value, message).toBe(expected), toBeVisible: async () => {} }) })); -it("rebinds both remote runs, saves managed bytes, and reads sealed sandbox bytes after restart", async () => { +it.each([ + { missingLF: false, incomplete: false, diagnosticSaveFails: false }, + { missingLF: true, incomplete: false, diagnosticSaveFails: false }, + { missingLF: false, incomplete: true, diagnosticSaveFails: false }, + { missingLF: false, incomplete: true, diagnosticSaveFails: true }, +])("preserves remote memory and terminal failures ($missingLF, $incomplete, $diagnosticSaveFails)", async ({ missingLF, incomplete, diagnosticSaveFails }) => { + const agentId = "11111111-1111-4111-8111-111111111111"; + const runIds = ["22222222-2222-4222-8222-222222222222", "33333333-3333-4333-8333-333333333333"]; const root = await mkdtemp(join(tmpdir(), "pi-remote-memory-")); const task = piNativeTasks.find(row => row.id === "agent-files-fresh-run")!; const issues: any[] = [], runs: any[] = [], cleanup: Array<() => Promise> = []; const captures: any[] = [], evidence = new Map(); - let personal = "", restarted = false, readAfterFinish = false; + let personal = "", restarted = false, readAfterFinish = false, parentSeeded = false, closed = 0, diagnosticAttempts = 0; + const terminalError = new Error("remote_native_fixture:terminal_evidence_incomplete"); + const originalDiagnostic = remoteFixtures.remoteNativeIncompleteTerminalEvidence; + const diagnostic = vi.spyOn(remoteFixtures, "remoteNativeIncompleteTerminalEvidence").mockImplementation(error => error === terminalError + ? { complete: false, incompleteReasons: ["unwatched_directory"] } as any : originalDiagnostic(error)); browser.create = value => { + expect(parentSeeded).toBe(true); + expect(value.requireExplicitTitle).toBe(true); expect(value.prompt).toMatch(/^Read only bootstrap-/); expect(value.prompt).not.toContain("forbidden"); - const n = issues.length + 1; issues.push({ id: `issue-${n}`, title: value.title, status: "in_progress" }); runs.push({ id: `run-${n}`, status: "running", runtimeMode: "native", createdAt: `2026-09-29T00:00:0${n}Z` }); + const n = issues.length + 1; issues.push({ id: `issue-${n}`, title: "Provider-generated task name", companyId: "company", assigneeAgentId: agentId, status: "in_progress" }); runs.push({ id: runIds[n - 1], status: "running", runtimeMode: "native", createdAt: `2026-09-29T00:00:0${n}Z` }); + return { submittedAtMs: Date.now(), issueId: `issue-${n}` }; }; - const api = { post: async () => ({ name: "Pi remote project" }), get: async (path: string) => { + const api = { request: { put: async (path: string, input: any) => { + expect(path).toBe(`/api/agents/${agentId}/instructions-bundle/file`); + expect(input.data).toEqual({ path: PI_NATIVE_MEMORY_PARENT_SEED_PATH, content: PI_NATIVE_MEMORY_PARENT_SEED_CONTENT, baseHash: null }); + expect(issues).toHaveLength(0); expect(personal).toBe(""); parentSeeded = true; + return { ok: () => true, json: async () => ({ content: PI_NATIVE_MEMORY_PARENT_SEED_CONTENT }) }; + } }, post: async () => ({ name: "Pi remote project" }), get: async (path: string) => { if (path.endsWith("/issues?limit=100")) return issues; if (path.endsWith("/heartbeat-runs?limit=100")) return runs; if (/\/api\/issues\/issue-[12]$/.test(path)) return issues.find(row => path.endsWith(row.id)); - if (/\/api\/heartbeat-runs\/run-[12]$/.test(path)) return runs.find(row => path.endsWith(row.id)); + if (runs.some(row => path === `/api/heartbeat-runs/${row.id}`)) return runs.find(row => path.endsWith(row.id)); if (path.endsWith("/interactions") || path.endsWith("/comments")) return []; - if (path.includes("instructions-bundle/file?")) return { content: personal }; - if (path.includes("/events?")) return path.includes("run-1/") ? [{ eventType: "instruction_save", seq: 1, payload: { state: "saved" } }, { eventType: "tool.execution.completed", seq: 2, payload: { prpEvent: { payload: { schema: "paperclip.tool.execution.v1", transport: "builtin", operation: "edit", name: "write", status: "failed", target: null, executionId: "tool-1", output: "Pi tool path is outside its assigned workspace and agent files" } } } }] : []; + if (path.includes("instructions-bundle/file?")) return { content: decodeURIComponent(path.split("?path=")[1]!) === PI_NATIVE_MEMORY_PARENT_SEED_PATH ? PI_NATIVE_MEMORY_PARENT_SEED_CONTENT : personal }; + if (path.includes("/events?")) { + const runId = runIds.find(id => path.includes(`${id}/`))!; + const nativeRead = { eventType: "tool.execution.completed", seq: 3, payload: { prpEvent: { payload: { + schema: "paperclip.tool.execution.v1", transport: "builtin", name: "read", operation: "read", status: "completed", + target: `.paperclip-runtime/agent-files/${agentId}/${runId}/memory/pi-native.txt`, readOnly: true, outputTruncated: false, executionId: path.includes(`${runIds[0]}/`) ? "read-1" : "read-2", + // Missing-LF controls still model the intended complete native read; + // their independent persisted bytes fail the existing byte assertion. + output: JSON.stringify({ content: [{ type: "text", text: personal.endsWith("\n") ? personal : `${personal}\n` }] }), + } } } }; + return path.includes(`${runIds[0]}/`) ? [{ eventType: "instruction_save", seq: 1, payload: { state: "saved" } }, { eventType: "tool.execution.completed", seq: 2, payload: { prpEvent: { payload: { schema: "paperclip.tool.execution.v1", transport: "builtin", operation: "edit", name: "write", status: "failed", target: null, executionId: "tool-1", output: "Pi tool path is outside its assigned workspace and agent files" } } } }, nativeRead] : [nativeRead]; + } throw new Error(`Unexpected fixture path ${path}`); } }; const remoteBootstrap = { prompt: (nonce: string) => `Read only bootstrap-${nonce}`, bindAndRelease: async (input: any) => { - const ordinal = runs.length; expect(input.runId).toBe(`run-${ordinal}`); + const ordinal = runs.length; expect(input.runId).toBe(runIds[ordinal - 1]); const identity = { pid: 100 + ordinal, ppid: 1, startTicks: String(ordinal), bootId: `boot-${ordinal}` }; const targets: any = ordinal === 1 ? { "@cross-root": { absent: false, sha256: `sha256:${createHash("sha256").update(input.crossRoot.initialText).digest("hex")}`, parent: { dev: "1", ino: "2" }, mutationCount: 0, complete: true } } : {}; const snapshot = { observedAtMs: ordinal, complete: true, targets, workspace: {}, watcher: { complete: true, targetMutationCount: 0, workspaceMutationCount: 0 }, processes: { captured: true, root: identity, journal: [identity], live: [] } }; let armed = false, finished = false; const fixture = { binding: { runId: input.runId }, remoteCwd: `/remote/run-${ordinal}`, outsideTarget: ordinal === 1 ? `/tmp/owned-${ordinal}/cross-root-target` : null, - snapshot: async () => { armed = true; return snapshot; }, finish: async () => { finished = true; return snapshot; }, close: async () => {}, + snapshot: async () => { armed = true; return snapshot; }, finish: async () => { if (incomplete) throw terminalError; finished = true; return snapshot; }, close: async () => { closed++; }, readFile: async (path: string) => { expect(finished).toBe(true); expect(restarted).toBe(true); expect(path).toBe("pi-agent-memory-proof.txt"); readAfterFinish = true; return Buffer.from(personal); }, }; const action = await input.actionPrompt(fixture); expect(armed).toBe(true); if (ordinal === 1) { - expect(action).toContain(fixture.outsideTarget); const literal = /Write exactly (".*?") to memory\/pi-native.txt/.exec(action)?.[1]; expect(literal).toBeDefined(); personal = JSON.parse(literal!); expect(personal).toMatch(/^[a-f0-9]{32}\n$/); + expect(action).toContain(fixture.outsideTarget); + const content = /```json\n(.*?)\n```/s.exec(action)?.[1]; + expect(content).toBeDefined(); + personal = JSON.parse(content!).content; + expect(personal).toMatch(/^[a-f0-9]{32}\n$/); + expect(Buffer.byteLength(personal, "utf8")).toBe(33); + if (missingLF) personal = personal.slice(0, -1); } else { expect(action).not.toContain(personal.trim()); expect(input.targets).toEqual(["pi-agent-memory-proof.txt"]); } issues.at(-1).status = "done"; runs.at(-1).status = "succeeded"; captures.push(fixture.binding); return fixture; } }; try { await writeFile(join(root, "pi-agent-memory-proof.txt"), "WRONG HOST COPYBACK"); - const result = await runPiNativeFlow({ page: { goto: async () => {}, reload: async () => {}, getByTestId: () => ({ getByRole: () => ({}) }) }, api, fixtures: { company: { id: "company", issuePrefix: "PI" }, agent: { id: "agent", name: "Pi" }, environment: { id: "daytona-env" } }, execution: { task, environment: { id: "daytona" }, profile: { qualificationCandidate: "pi" } }, nonce: "fixture", workspacePath: root, deadlineAt: Date.now() + 2000, - restart: async () => { expect(evidence.has("pi-remote-1-cross-root-final.json")).toBe(true); restarted = true; }, observe: () => {}, capture: async () => {}, evidence: async (name: string, value: unknown) => { evidence.set(name, value); }, remoteBootstrap, registerCleanupAssertion: (fn: () => Promise) => cleanup.push(fn) } as any); - expect(result.checks.every(check => check.passed)).toBe(true); expect(captures).toEqual([{ runId: "run-1" }, { runId: "run-2" }]); expect(readAfterFinish).toBe(true); expect(cleanup).toHaveLength(2); for (const fn of cleanup) await fn(); - } finally { await rm(root, { recursive: true, force: true }); } + const call = runPiNativeFlow({ page: { goto: async () => {}, reload: async () => {}, getByTestId: () => ({ getByRole: () => ({}) }) }, api, fixtures: { company: { id: "company", issuePrefix: "PI" }, agent: { id: agentId, name: "Pi" }, environment: { id: "daytona-env" } }, execution: { task, environment: { id: "daytona" }, profile: { qualificationCandidate: "pi" } }, nonce: "fixture", workspacePath: root, deadlineAt: Date.now() + 2000, + restart: async () => { expect(evidence.has("pi-remote-1-cross-root-final.json")).toBe(true); restarted = true; }, observe: () => {}, capture: async () => {}, evidence: async (name: string, value: unknown) => { if (name.endsWith("incomplete-terminal.json")) { diagnosticAttempts++; if (diagnosticSaveFails) throw new Error("diagnostic storage unavailable"); } evidence.set(name, value); }, remoteBootstrap, registerCleanupAssertion: (fn: () => Promise) => cleanup.push(fn) } as any); + if (missingLF) { + await expect(call).rejects.toThrow("Public managed-file API contains the exact native-write bytes"); + expect(evidence.get("pi-agent-files-first-save.json").personal.content).toBe(personal); + expect(Buffer.byteLength(personal)).toBe(32); + expect(restarted).toBe(false); expect(runs).toHaveLength(1); + return; + } + if (incomplete) { + await expect(call).rejects.toBe(terminalError); + expect(cleanup).toHaveLength(1); + await expect(cleanup[0]!()).rejects.toBe(terminalError); + expect(closed).toBe(1); expect(restarted).toBe(false); + expect(evidence.has("pi-remote-1-incomplete-terminal.json")).toBe(!diagnosticSaveFails); + expect(diagnosticAttempts).toBe(diagnosticSaveFails ? 2 : 1); + return; + } + const result = await call; + expect(result.checks.every(check => check.passed)).toBe(true); expect(captures).toEqual(runIds.map(runId => ({ runId }))); expect(readAfterFinish).toBe(true); expect(cleanup).toHaveLength(2); for (const fn of cleanup) await fn(); + } finally { diagnostic.mockRestore(); await rm(root, { recursive: true, force: true }); } }); diff --git a/tests/runner-e2e/pi-native-restart-flow.test.ts b/tests/runner-e2e/pi-native-restart-flow.test.ts index b6e0be51c8..f9a6903954 100644 --- a/tests/runner-e2e/pi-native-restart-flow.test.ts +++ b/tests/runner-e2e/pi-native-restart-flow.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { expect, it, vi } from "vitest"; import { gradePiRestartCompletion, observePiRestartPending, runPiPendingControllerRestart } from "./pi-native-restart-flow.js"; import { validatePrpStructuredRunResult } from "../../packages/paperclip-runner/src/protocol/replay-contract.js"; @@ -17,7 +18,7 @@ function fixture(adapter = "acpx-runtime-sidecar") { normalizedSessionId: "session", sourceInstanceId: "runner", sourceSeq, sourceEventId: `runner:run:${sourceSeq}`, payload } }, }); const state = { issue: { id: "issue", companyId: "company", status: "in_progress" }, - runs: [{ id: "run", companyId: "company", nativeIssueId: "issue", runtimeMode: "native", status: "running", nativeSessionId: "session", runnerInstanceId: "runner" } as Row], + runs: [{ id: "run", companyId: "company", nativeIssueId: "issue", runtimeMode: "native", status: "running", nativeSessionId: "session", runnerInstanceId: "runner", processPid: 200, processGroupId: 200, processStartedAt: "2026-10-02T13:55:27.000Z" } as Row], interactions: [{ id: "interaction", companyId: "company", issueId: "issue", kind: "ask_user_questions", status: "pending", result: null, sourceRunId: "run", continuationPolicy: "none", resolverPolicy: "human_only", idempotencyKey: "paperclip-runner-question:run:request", payload: { runtimeRequestId: "request", questionSet } } as Row] }; @@ -50,6 +51,56 @@ it.each(["acpx-runtime", "acpx-runtime-sidecar"])("accepts mixed runner/control- expect(gradePiRestartCompletion(f.state, f.events, pending, "hidden", proof)).toBe(true); }); +function actualPendingPrefix() { + return JSON.parse(readFileSync(new URL("./fixtures/pi-native-restart-actual-prefix.json", import.meta.url), "utf8")) as { + state: { issue: Row; runs: Row[]; interactions: Row[] }; events: Row[]; + }; +} + +it.each(["omitted", "null"])("accepts the actual pre-start submission with %s turn ID", representation => { + const f = actualPendingPrefix(); + if (representation === "null") f.events[0]!.payload.prpEvent.turnId = null; + expect(observePiRestartPending(f.state, f.events, "company")).toMatchObject({ + runId: "run", turnId: "turn", nativeSessionId: "session", sourceInstanceId: "runner", createdSourceSeq: 195, + }); +}); + +it.each([ + ["missing start", (f: ReturnType) => { f.events.splice(1, 1); }], + ["start without assigned turn", (f: ReturnType) => { delete f.events[1]!.payload.prpEvent.turnId; }], + ["start with foreign turn", (f: ReturnType) => { f.events[1]!.payload.prpEvent.turnId = "other"; }], + ["foreign submission turn", (f: ReturnType) => { f.events[0]!.payload.prpEvent.turnId = "other"; }], + ["foreign submission session", (f: ReturnType) => { f.events[0]!.payload.prpEvent.normalizedSessionId = "other"; }], + ["foreign submission producer", (f: ReturnType) => { + Object.assign(f.events[0]!.payload.prpEvent, { sourceInstanceId: "other", sourceEventId: "other:run:6" }); + }], + ["submission after start", (f: ReturnType) => { + f.events[0]!.seq = 29; + Object.assign(f.events[0]!.payload.prpEvent, { sourceSeq: 8, sourceEventId: "runner:run:8" }); + }], + ["start after request", (f: ReturnType) => { + f.events[1]!.seq = 223; + Object.assign(f.events[1]!.payload.prpEvent, { sourceSeq: 196, sourceEventId: "runner:run:196" }); + }], + ["duplicate submission", (f: ReturnType) => { + const row = structuredClone(f.events[0]!); row.seq = 26; + Object.assign(row.payload.prpEvent, { sourceSeq: 7, sourceEventId: "runner:run:7" }); + Object.assign(f.events[1]!.payload.prpEvent, { sourceSeq: 8, sourceEventId: "runner:run:8" }); + f.events.push(row); + }], + ["duplicate start", (f: ReturnType) => { + const row = structuredClone(f.events[1]!); row.seq = 29; + Object.assign(row.payload.prpEvent, { sourceSeq: 8, sourceEventId: "runner:run:8" }); f.events.push(row); + }], + ["later unbound turn event", (f: ReturnType) => { + const row = structuredClone(f.events[1]!); row.seq = 29; row.eventType = "turn.progress"; + Object.assign(row.payload.prpEvent, { eventType: "turn.progress", turnId: null, sourceSeq: 8, sourceEventId: "runner:run:8" }); f.events.push(row); + }], +] as const)("rejects %s around an unbound submission", (_label, mutate) => { + const f = actualPendingPrefix(); mutate(f); + expect(() => observePiRestartPending(f.state, f.events, "company")).toThrow("Pi native restart"); +}); + it.each([ ["no native event", (f: ReturnType) => { f.events.length = 0; }], ["wrong origin", (f: ReturnType) => { f.request.origin.provider = "cursor"; }], @@ -113,16 +164,20 @@ it.each([null, {}, { status: "answered", value: "guess" }, { status: "cancelled" }, ); -async function flow(failure?: "replaced" | "wrong-file" | "missing-resolution") { +async function flow(failure?: "replaced" | "wrong-file" | "missing-resolution" | "replaced-runner", issueRef = "issue") { const f = fixture(), evidence = new Map(), checkpoints: string[] = []; + Object.assign(f.state.issue, { identifier: "RUN-1" }); let answer = "", restarts = 0, submissions = 0, proof: unknown, resolvePost: ((value: unknown) => void) | undefined; let postPredicate: ((value: any) => boolean) | undefined; const composer: any = { count: () => 1, filter: () => composer, locator: () => composer, first: () => composer, fill: async (value: string) => { expect(restarts).toBe(1); answer = value; } }; const button: any = { count: () => 1, filter: () => button, click: async () => { expect(restarts).toBe(1); expect(answer).toMatch(/^PI-RESTART-[a-f0-9]{32}$/); submissions++; - const request = { url: () => "http://fixture/api/issues/issue/interactions/interaction/respond", method: () => "POST", + const request = { url: () => `http://fixture/api/issues/${issueRef}/interactions/interaction/respond`, method: () => "POST", postDataJSON: () => ({ answers: [{ questionId: "answer", optionIds: [], otherText: answer }] }) }; + expect(postPredicate!({ ...request, url: () => "http://fixture/api/issues/OTHER-1/interactions/interaction/respond" })).toBe(false); + expect(postPredicate!({ ...request, url: () => `http://fixture/api/issues/${issueRef}/interactions/other/respond` })).toBe(false); + expect(postPredicate!({ ...request, method: () => "GET" })).toBe(false); expect(postPredicate!(request)).toBe(true); resolvePost!(request); proof = f.finish(answer); if (failure === "missing-resolution") f.events.splice(1, 1); } }; @@ -130,23 +185,25 @@ async function flow(failure?: "replaced" | "wrong-file" | "missing-resolution") waitForRequest: (predicate: (value: any) => boolean) => { postPredicate = predicate; return new Promise(resolve => { resolvePost = resolve; }); } }; const result = runPiPendingControllerRestart({ page, companyId: "company", deadlineAt: Date.now() + 1000, load: async () => structuredClone(f.state), events: async () => structuredClone(f.events), - restart: async () => { + restart: async identity => { + expect(identity).toEqual({ processPid: 200, processGroupId: 200, processStartedAt: "2026-10-02T13:55:27.000Z" }); expect(submissions).toBe(0); expect(f.state.runs[0]!.status).toBe("running"); expect(f.state.interactions[0]!.status).toBe("pending"); expect(answer).toBe(""); restarts++; checkpoints.push("restart"); if (failure === "replaced") f.state.interactions[0]!.id = "replacement"; + if (failure === "replaced-runner") f.state.runs[0]!.processPid = f.state.runs[0]!.processGroupId = 201; }, settle: async () => structuredClone(f.state), readProof: async () => failure === "wrong-file" ? { status: "answered", value: "guess" } : proof, capture: async id => { checkpoints.push(id); }, evidence: async (name, data) => { evidence.set(name, structuredClone(data)); }, }); if (failure) await expect(result).rejects.toThrow("Pi native restart"); else expect((await result).every(check => check.passed)).toBe(true); - expect(restarts).toBe(1); expect(submissions).toBe(failure === "replaced" ? 0 : 1); + expect(restarts).toBe(1); expect(submissions).toBe(["replaced", "replaced-runner"].includes(failure ?? "") ? 0 : 1); expect(checkpoints.slice(0, 3)).toEqual(["pi-restart-pending", "restart", "reload"]); expect(evidence.has("pi-native-restart-before.json")).toBe(true); expect(evidence.has("pi-native-restart-after.json")).toBe(true); expect(evidence.has("pi-native-restart-checks.json")).toBe(true); expect(JSON.stringify(evidence.get("pi-native-restart-before.json"))).not.toContain("PI-RESTART-"); - if (failure !== "replaced") expect(evidence.has("pi-native-restart-final.json")).toBe(true); + if (!["replaced", "replaced-runner"].includes(failure ?? "")) expect(evidence.has("pi-native-restart-final.json")).toBe(true); } -it("restarts while unanswered, reloads, then submits hidden text through the exact browser route", () => flow()); -it.each(["replaced", "wrong-file", "missing-resolution"] as const)("retains evidence and fails whole flow on %s", failure => flow(failure)); +it.each(["issue", "RUN-1"])("restarts while unanswered and submits through the exact %s browser route", issueRef => flow(undefined, issueRef)); +it.each(["replaced", "wrong-file", "missing-resolution", "replaced-runner"] as const)("retains evidence and fails whole flow on %s", failure => flow(failure)); diff --git a/tests/runner-e2e/pi-native-restart-flow.ts b/tests/runner-e2e/pi-native-restart-flow.ts index ff8b2eb3c6..34a34739d3 100644 --- a/tests/runner-e2e/pi-native-restart-flow.ts +++ b/tests/runner-e2e/pi-native-restart-flow.ts @@ -1,3 +1,4 @@ +import { parseRestartRunnerIdentity, type RestartRunnerIdentity } from "./process-tree-owner.js"; import { createHash, randomBytes } from "node:crypto"; import { expect, type Page } from "@playwright/test"; import { canonicalJson } from "../../packages/shared/src/portability-hash.js"; @@ -66,7 +67,19 @@ function inspect(state: State, events: readonly Row[], companyId: string, header && id(question.id) && question.answerMode === "text" && question.header === header && card.payload?.runtimeRequestId === request.requestId && digest(card.payload.questionSet) === digest(questionSet) && card.idempotencyKey === `paperclip-runner-question:${run.id}:${request.requestId}`, "canonical Pi input request identity missing"); - requireProof(rows.filter(x => x.event.turnId != null || x.event.eventType.startsWith("turn.") || x.event.eventType.startsWith("runtime_request.")).every(({ event }) => event.turnId === request.turnId + const submitted = rows.filter(x => x.event.eventType === "turn.submitted"); + const unboundSubmission = submitted.find(x => x.event.turnId == null); + if (unboundSubmission) { + // The local runner records submission before ACP assigns the provider turn ID. + // Only that unique, ordered pre-start receipt may omit the turn identity. + const started = rows.filter(x => x.event.eventType === "turn.started"); + requireProof(submitted.length === 1 && started.length === 1 + && started[0]!.event.turnId === request.turnId + && unboundSubmission.row.seq < started[0]!.row.seq && started[0]!.row.seq < opening.row.seq + && unboundSubmission.event.sourceSeq < started[0]!.event.sourceSeq && started[0]!.event.sourceSeq < opening.event.sourceSeq, + "unbound submission is not the unique pre-start receipt"); + } + requireProof(rows.filter(x => x.event.turnId != null || x.event.eventType.startsWith("turn.") || x.event.eventType.startsWith("runtime_request.")).every(({ row, event }) => (event.turnId === request.turnId || row === unboundSubmission?.row) && event.normalizedSessionId === run.nativeSessionId && event.sourceInstanceId === (event.sourceKind === "runner" ? run.runnerInstanceId : `${run.runnerInstanceId}:control`)), "turn, native session or producer was replaced"); requireProof(rows.filter(x => x.event.sourceKind === "control_plane").every(x => x.event.turnId === request.turnId), "control-plane result belongs to another turn"); @@ -120,7 +133,7 @@ export function gradePiRestartCompletion(state: State, events: readonly Row[], p export async function runPiPendingControllerRestart(input: { page: Page; companyId: string; deadlineAt: number; load(): Promise; events(runId: string): Promise; - restart(): Promise; settle(): Promise; readProof(): Promise; + preserveLocalRunner?: boolean; restart(preserveRunner?: RestartRunnerIdentity): Promise; settle(): Promise; readProof(): Promise; capture(id: string, label: string, file: string): Promise; evidence(name: string, data: unknown): Promise; }): Promise { const checks: Check[] = [], answer = `PI-RESTART-${randomBytes(16).toString("hex")}`; @@ -138,15 +151,19 @@ export async function runPiPendingControllerRestart(input: { const composer = () => input.page.getByTestId("question-text-answer-composer").filter({ visible: true }); await expect(composer()).toHaveCount(1); await input.capture("pi-restart-pending", "Pi native question before controller restart", "pi-restart-pending.png"); - await input.restart(); + const runnerIdentity = input.preserveLocalRunner === false ? undefined : parseRestartRunnerIdentity(before.state.runs[0]); + await input.restart(runnerIdentity); await input.page.reload(); const after = await snapshot("pi-native-restart-after.json"), resumed = observePiRestartPending(after.state, after.events, input.companyId); check("restart-same-pending-native-request", digest(resumed) === digest(pending), "Controller restart retained the same unanswered request, run, turn, native session and producer"); + if (runnerIdentity) check("restart-same-live-runner-identity", digest(parseRestartRunnerIdentity(after.state.runs[0])) === digest(runnerIdentity), + "The original durable runner PID, process group and start identity survived the controller restart"); await expect(composer()).toHaveCount(1); await input.capture("pi-restart-reconnected", "Same Pi question after controller restart", "pi-restart-reconnected.png"); await composer().locator('[contenteditable="true"],textarea').first().fill(answer); - const route = `/api/issues/${pending.issueId}/interactions/${pending.interactionId}/respond`; - const submitted = input.page.waitForRequest(request => new URL(request.url()).pathname === route && request.method() === "POST", + const issueRefs = [pending.issueId, after.state.issue.identifier].filter(id); + const routes = new Set(issueRefs.map(issueRef => `/api/issues/${encodeURIComponent(issueRef)}/interactions/${pending.interactionId}/respond`)); + const submitted = input.page.waitForRequest(request => routes.has(new URL(request.url()).pathname) && request.method() === "POST", { timeout: Math.max(1, input.deadlineAt - Date.now()) }); const button = input.page.getByRole("button", { name: after.state.interactions[0]!.payload.questionSet.submitLabel ?? "Submit answers", exact: true }).filter({ visible: true }); await expect(button).toHaveCount(1); await button.click(); diff --git a/tests/runner-e2e/pi-provider-fault.py b/tests/runner-e2e/pi-provider-fault.py index d5cb349728..56537a8b53 100644 --- a/tests/runner-e2e/pi-provider-fault.py +++ b/tests/runner-e2e/pi-provider-fault.py @@ -72,6 +72,45 @@ def digest(data): return hashlib.sha256(data).hexdigest() +def checked_runner_executable(path): + # Production stages a verified preinstalled runner with ln -sfn. Admit + # that named link only while its identity and resolved regular file remain + # stable; inspect still requires both the pinned hash and /proc/exe inode. + before = os.lstat(path) + require(stat.S_ISREG(before.st_mode) or stat.S_ISLNK(before.st_mode), 'runner_file_shape') + resolved = os.path.realpath(path) + content, inode = checked_file(resolved, 256 * 1024 * 1024) + after = os.lstat(path) + identity = lambda s: (s.st_dev, s.st_ino, s.st_mode, s.st_mtime_ns, s.st_ctime_ns) + require(identity(before) == identity(after) and os.path.realpath(path) == resolved, 'runner_link_changed') + return content, inode + + +def parse_closure_metadata(content, expected_pin): + # Match production parseNativeAcpxDistributionEntries: the profile pins + # canonical entries, not the formatting or outer JSON file bytes. + manifest = json.loads(content) + entries = manifest.get('entries') if isinstance(manifest, dict) else None + require(isinstance(entries, list) and 0 < len(entries) <= 30000, 'closure_inventory') + normalized, previous, total = [], '', 0 + for entry in entries: + require(isinstance(entry, dict) and set(entry) == {'path', 'sha256', 'size', 'executable'}, 'closure_entry_shape') + path = entry['path'] + require(isinstance(path, str) and 0 < len(path) <= 4096 and not os.path.isabs(path) + and not re.search(r'[\x00-\x1f\x7f\\]', path) + and all(part not in ('', '.', '..') for part in path.split('/')) + and path > previous and path != 'manifest.json' and not path.startswith('.paperclip-'), 'closure_entry_path') + require(isinstance(entry['sha256'], str) and re.fullmatch(r'[a-f0-9]{64}', entry['sha256']) + and type(entry['size']) is int and 0 <= entry['size'] <= 384 * 1024 * 1024 + and type(entry['executable']) is bool, 'closure_entry_metadata') + total += entry['size']; require(total <= 1024 * 1024 * 1024, 'closure_tree_bound') + previous = path + normalized.append({key: entry[key] for key in ('path', 'sha256', 'size', 'executable')}) + canonical = json.dumps(normalized, separators=(',', ':'), ensure_ascii=False).encode('utf8') + require(re.fullmatch(r'[a-f0-9]{64}', expected_pin) and digest(canonical) == expected_pin, 'closure_pin') + return normalized + + def inspect(config): require(set(config) == {'root', 'binding', 'runtimeEnvironmentLeaseId', 'runnerdSha256', 'closureSha256'}, 'config_keys') binding, expected = config['binding'], config['root'] @@ -86,14 +125,12 @@ def inspect(config): and flag(root_args, '--environment-lease-id') == config['runtimeEnvironmentLeaseId'] and flag(root_args, '--lifecycle-mode') == 'per_turn', 'root_binding') require(re.fullmatch(re.escape(runtime_root) + r'/sessions/[a-f0-9]{64}/runner', flag(root_args, '--state-dir')), 'root_session') - runner_bytes, runner_inode = checked_file(runner, 256 * 1024 * 1024) + runner_bytes, runner_inode = checked_runner_executable(runner) executable = os.stat(f'/proc/{root["pid"]}/exe') require((executable.st_dev, executable.st_ino) == runner_inode and 'sha256:' + digest(runner_bytes) == config['runnerdSha256'], 'runner_executable') closure_bytes, _ = checked_file(PACK + '/provider-assets/pi/linux-x64/native-closure.json', 4 * 1024 * 1024) - require(digest(closure_bytes) == config['closureSha256'], 'closure_pin') - manifest = json.loads(closure_bytes) - entries = {e['path']: e for e in manifest['entries']} - require(len(entries) == len(manifest['entries']), 'closure_duplicate') + admitted_entries = parse_closure_metadata(closure_bytes, config['closureSha256']) + entries = {e['path']: e for e in admitted_entries} for name in (NODE, ENTRY, EXTENSION, 'pi-entry.cjs', 'node_modules/pi-acp/dist/index.js', 'node_modules/pi-acp/dist/paperclip-runtime.js'): require(name in entries and re.fullmatch(r'[a-f0-9]{64}', entries[name]['sha256']), 'closure_entry') pids = [int(p) for p in os.listdir('/proc') if p.isdigit() and int(p) > 1] @@ -128,7 +165,15 @@ def inspect(config): continue distribution = parent_args[3][:-len('/pi-entry.cjs')] require(re.fullmatch(r'/tmp/paperclip-acpx-native-[A-Za-z0-9_-]+/distribution', distribution), 'snapshot_path') - require(parent_args == [distribution + '/' + NODE, '--require', distribution + '/' + GUARD, distribution + '/pi-entry.cjs'], 'wrapper_parent') + require(parent_args[1:] == ['--require', distribution + '/' + GUARD, distribution + '/pi-entry.cjs'], 'wrapper_parent') + descriptor = None + if parent_args[0] != distribution + '/' + NODE: + # Production nativeBootstrap executes the held Node through child + # FD 3 (unfenced) or 7 (guardian/credential fences). The child's + # cmdline retains /proc/self/fd/N; it does not name the source path. + match = re.fullmatch(r'/proc/self/fd/(3|7)', parent_args[0]) + require(match is not None, 'wrapper_parent') + descriptor = f'/proc/{parent}/fd/{match.group(1)}' # Pi sets process.title and overwrites Linux argv. The exact pinned # parent's launch code attests the entrypoint; title alone never selects. require(argv(pid) == ['pi'], 'pi_process_title') @@ -145,6 +190,9 @@ def inspect(config): require((exe.st_dev, exe.st_ino) == identities[NODE], 'pi_executable') parent_exe = os.stat(f'/proc/{parent}/exe') require((parent_exe.st_dev, parent_exe.st_ino) == identities[NODE], 'wrapper_executable') + if descriptor is not None: + held_exe = os.stat(descriptor) + require((held_exe.st_dev, held_exe.st_ino) == identities[NODE], 'wrapper_descriptor') for identity in chain: require(proc(identity['pid'], boot) == identity, 'ancestry_changed') matches.append({'target': table[pid], 'ancestry': chain, 'nodeSha256': 'sha256:' + entries[NODE]['sha256'], diff --git a/tests/runner-e2e/pi-provider-fault.test.py b/tests/runner-e2e/pi-provider-fault.test.py index dff4d0149d..281c78cbc4 100644 --- a/tests/runner-e2e/pi-provider-fault.test.py +++ b/tests/runner-e2e/pi-provider-fault.test.py @@ -32,18 +32,73 @@ def check_cancelled(): NAMES = [fault.NODE, fault.ENTRY, fault.EXTENSION, 'pi-entry.cjs', 'node_modules/pi-acp/dist/index.js', 'node_modules/pi-acp/dist/paperclip-runtime.js'] +class ClosureMetadataTests(unittest.TestCase): + def setUp(self): + self.entries = [ + {'path': name, 'sha256': fault.digest(name.encode()), 'size': len(name), 'executable': name == fault.NODE} + for name in sorted(NAMES) + ] + self.canonical = json.dumps(self.entries, separators=(',', ':'), ensure_ascii=False).encode() + self.pin = fault.digest(self.canonical) + self.manifest = {'entries': self.entries} + + def test_canonical_entries_pin_admits_metadata(self): + raw = json.dumps(self.manifest, indent=2).encode() + self.assertNotEqual(fault.digest(raw), self.pin) + self.assertEqual(fault.parse_closure_metadata(raw, self.pin), self.entries) + + def test_metadata_formatting_and_property_order_preserve_pin(self): + reordered = {'entries': [dict(reversed(list(entry.items()))) for entry in self.entries]} + for manifest in [self.manifest, reordered]: + for indent in [None, 2, 4]: + with self.subTest(indent=indent): + self.assertEqual(fault.parse_closure_metadata(json.dumps(manifest, indent=indent).encode(), self.pin), self.entries) + + def test_raw_metadata_hash_is_not_a_closure_pin(self): + raw = json.dumps(self.manifest, indent=2).encode() + with self.assertRaisesRegex(RuntimeError, 'closure_pin'): + fault.parse_closure_metadata(raw, fault.digest(raw)) + + def test_changed_entry_is_rejected_by_the_pinned_digest(self): + changed = json.loads(json.dumps(self.manifest)) + changed['entries'][0]['sha256'] = '0' * 64 + with self.assertRaisesRegex(RuntimeError, 'closure_pin'): + fault.parse_closure_metadata(json.dumps(changed).encode(), self.pin) + + def test_unsafe_duplicate_unsorted_and_invalid_metadata_fail_closed(self): + mutations = [ + lambda m: m['entries'][0].update(path='../escape'), + lambda m: m['entries'][0].update(path='.paperclip-native-entry.cjs'), + lambda m: m['entries'].insert(1, m['entries'][0].copy()), + lambda m: m['entries'].reverse(), + lambda m: m['entries'][0].update(size=True), + lambda m: m['entries'][0].update(executable=1), + lambda m: m['entries'][0].update(extra='foreign'), + ] + for index, mutate in enumerate(mutations): + changed = json.loads(json.dumps(self.manifest)) + mutate(changed) + with self.subTest(index=index), self.assertRaises(RuntimeError): + fault.parse_closure_metadata(json.dumps(changed).encode(), self.pin) + for invalid in [None, [], {}, {'entries': []}]: + with self.subTest(invalid=invalid), self.assertRaises(RuntimeError): + fault.parse_closure_metadata(json.dumps(invalid).encode(), self.pin) + + class IdentityTests(unittest.TestCase): def setUp(self): self.files = {DIST + '/' + n: (n.encode(), (1, 100 + i)) for i, n in enumerate(NAMES)} self.files[RUNTIME + '/bin/paperclip-runnerd'] = (b'runner', (1, 90)) - closure = json.dumps({'entries': [{'path': n, 'sha256': fault.digest(n.encode()), 'size': len(n)} for n in NAMES]}).encode() + entries = [{'path': n, 'sha256': fault.digest(n.encode()), 'size': len(n), 'executable': n == fault.NODE} for n in sorted(NAMES)] + closure = json.dumps({'entries': entries}, indent=2).encode() self.files[fault.PACK + '/provider-assets/pi/linux-x64/native-closure.json'] = (closure, (1, 80)) self.config = {'root': ROOT, 'binding': {'remoteCwd': '/workspace', 'runId': 'run'}, 'runtimeEnvironmentLeaseId': 'workspace-id', - 'runnerdSha256': 'sha256:' + fault.digest(b'runner'), 'closureSha256': fault.digest(closure)} + 'runnerdSha256': 'sha256:' + fault.digest(b'runner'), 'closureSha256': fault.digest(json.dumps(entries, separators=(',', ':'), ensure_ascii=False).encode())} self.table = {21: ROOT, 22: PARENT, 23: TARGET} self.args = {21: [RUNTIME + '/bin/paperclip-runnerd', '--run-id', 'run', '--environment-lease-id', 'workspace-id', '--lifecycle-mode', 'per_turn', '--state-dir', RUNTIME + '/sessions/' + 'a' * 64 + '/runner'], 22: [DIST + '/' + fault.NODE, '--require', DIST + '/' + fault.GUARD, DIST + '/pi-entry.cjs'], 23: ['pi']} self.patches = [patch.object(fault, 'proc', side_effect=lambda pid, boot: self.table[pid]), patch.object(fault, 'argv', side_effect=lambda pid: self.args[pid]), + patch.object(fault, 'checked_runner_executable', side_effect=lambda p: self.files[p]), patch.object(fault, 'checked_file', side_effect=lambda p, *a: self.files[p]), patch.object(Path, 'read_text', return_value=BOOT), patch.object(os, 'listdir', return_value=['21', '22', '23']), patch.object(os, 'getpgid', return_value=21), patch.object(os, 'lstat', return_value=SimpleNamespace(st_mode=0o40500)), patch.object(os.path, 'realpath', side_effect=lambda p: p), @@ -57,6 +112,28 @@ class IdentityTests(unittest.TestCase): self.assertFalse(receipt['originalChildArgvAvailable']) self.assertEqual(receipt['ancestry'], [TARGET, PARENT, ROOT]) + def test_descriptor_launch_attests_the_same_pinned_wrapper(self): + for descriptor in [3, 7]: + self.args[22][0] = f'/proc/self/fd/{descriptor}' + with self.subTest(descriptor=descriptor): + self.assertEqual(fault.inspect(self.config)['target'], TARGET) + + def test_descriptor_launch_rejects_foreign_missing_or_unbound_fd(self): + for name in ['/proc/self/fd/8', '/proc/99/fd/7', '/foreign/node']: + self.args[22][0] = name + with self.subTest(name=name), self.assertRaisesRegex(RuntimeError, 'wrapper_parent'): + fault.inspect(self.config) + self.args[22][0] = '/proc/self/fd/7' + stat_original = os.stat + for problem in ['foreign', 'missing']: + def descriptor_stat(path): + if path == '/proc/22/fd/7': + if problem == 'missing': raise FileNotFoundError(path) + return SimpleNamespace(st_dev=1, st_ino=999) + return stat_original(path) + with self.subTest(problem=problem), patch.object(os, 'stat', side_effect=descriptor_stat), self.assertRaises((RuntimeError, FileNotFoundError)): + fault.inspect(self.config) + def test_wrong_metadata_never_selects(self): for field, value in [('closureSha256', '0' * 64), ('runtimeEnvironmentLeaseId', 'foreign'), ('runnerdSha256', 'sha256:' + '0' * 64)]: with self.subTest(field=field), self.assertRaises(RuntimeError): fault.inspect({**self.config, field: value}) @@ -82,6 +159,43 @@ class IdentityTests(unittest.TestCase): with patch.object(os, 'listdir', return_value=['21', '22', '23', '24']), self.assertRaisesRegex(RuntimeError, 'unique_pi_child'): fault.inspect(self.config) +class RunnerExecutableTests(unittest.TestCase): + def test_regular_and_preinstalled_link_resolve_to_the_same_inode(self): + with tempfile.TemporaryDirectory() as tmp: + executable = Path(tmp).resolve() / 'installed-runner' + executable.write_bytes(b'pinned runner') + link = executable.with_name('runtime-runner') + link.symlink_to(executable) + content, inode = fault.checked_runner_executable(str(link)) + self.assertEqual(content, b'pinned runner') + self.assertEqual((content, inode), fault.checked_runner_executable(str(executable))) + + def test_retargeted_link_is_rejected_after_read(self): + with tempfile.TemporaryDirectory() as tmp: + executable = Path(tmp).resolve() / 'installed-runner' + executable.write_bytes(b'pinned runner') + foreign = executable.with_name('foreign-runner') + foreign.write_bytes(b'foreign') + link = executable.with_name('runtime-runner') + link.symlink_to(executable) + original = fault.checked_file + def replace_during_read(*args): + result = original(*args) + link.unlink() + link.symlink_to(foreign) + return result + with patch.object(fault, 'checked_file', side_effect=replace_during_read), self.assertRaisesRegex(RuntimeError, 'runner_link_changed'): + fault.checked_runner_executable(str(link)) + + def test_missing_link_and_directory_never_admit_an_executable(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + link = root / 'runtime-runner' + link.symlink_to(root / 'missing') + with self.assertRaises(FileNotFoundError): fault.checked_runner_executable(str(link)) + with self.assertRaisesRegex(RuntimeError, 'runner_file_shape'): fault.checked_runner_executable(str(root)) + + class PidfdTests(unittest.TestCase): def test_changed_or_retired_pidfd_never_signals_and_always_closes(self): before = {'target': TARGET} @@ -103,6 +217,18 @@ class PidfdTests(unittest.TestCase): @unittest.skipUnless(sys.platform == 'linux' and hasattr(os, 'pidfd_open'), 'Hosted native Linux calibration required') def test_real_title_overwrite_then_exact_child_pidfd(self): + self.calibrate_real_child() + + @unittest.skipUnless(sys.platform == 'linux' and hasattr(os, 'pidfd_open'), 'Hosted native Linux descriptor calibration required') + def test_real_descriptor_launch_then_exact_child_pidfd(self): + for descriptor in [3, 7]: + with self.subTest(descriptor=descriptor): self.calibrate_real_child(descriptor) + + @unittest.skipUnless(sys.platform == 'linux' and hasattr(os, 'pidfd_open'), 'Hosted native Linux preinstalled-link calibration required') + def test_real_preinstalled_runner_link_then_exact_child_pidfd(self): + self.calibrate_real_child(7, runner_link=True) + + def calibrate_real_child(self, descriptor=None, runner_link=False): node = shutil.which('node'); self.assertIsNotNone(node) base = Path(tempfile.mkdtemp(prefix='pi-fault-calibration-', dir='/tmp')) snapshot = Path(tempfile.mkdtemp(prefix='paperclip-acpx-native-', dir='/tmp')) @@ -115,19 +241,34 @@ class PidfdTests(unittest.TestCase): for name in NAMES + [fault.GUARD]: p = distribution / name; p.parent.mkdir(parents=True, exist_ok=True); p.write_text('// fixture\n') shutil.copyfile(node, distribution / fault.NODE); (distribution / fault.NODE).chmod(0o500) - shutil.copyfile(node, runtime / 'bin/paperclip-runnerd'); (runtime / 'bin/paperclip-runnerd').chmod(0o500) + runner = runtime / 'bin/paperclip-runnerd' + if runner_link: + installed = base / 'preinstalled-runner' + shutil.copyfile(node, installed); installed.chmod(0o500) + runner.symlink_to(installed) + else: + shutil.copyfile(node, runner); runner.chmod(0o500) ready = workspace / 'child.json'; exited = workspace / 'exit.json' (distribution / fault.ENTRY).write_text('process.title="pi"; require("node:fs").writeFileSync(' + json.dumps(str(ready)) + ',JSON.stringify({pid:process.pid}));setInterval(()=>{},1000);') (distribution / 'pi-entry.cjs').write_text('const p=require("node:child_process").spawn(process.execPath,["--require",' + json.dumps(str(distribution / fault.GUARD)) + ',' + json.dumps(str(distribution / fault.ENTRY)) + '],{stdio:"ignore"});p.on("exit",(code,signal)=>require("node:fs").writeFileSync(' + json.dumps(str(exited)) + ',JSON.stringify({code,signal})));process.on("SIGTERM",()=>{if(p.exitCode!==null||p.signalCode!==null)process.exit(0);p.once("exit",()=>process.exit(0));p.kill("SIGTERM")});setInterval(()=>{},1000);') entries = [] - for name in NAMES: - p = distribution / name; content = p.read_bytes(); entries.append({'path': name, 'sha256': fault.digest(content), 'size': len(content)}) + for name in sorted(NAMES): + p = distribution / name; content = p.read_bytes(); entries.append({'path': name, 'sha256': fault.digest(content), 'size': len(content), 'executable': name == fault.NODE}) closure = json.dumps({'entries': entries}).encode(); (pack / 'provider-assets/pi/linux-x64/native-closure.json').write_bytes(closure) for directory, dirs, files in os.walk(snapshot): for f in files: p = Path(directory) / f; p.chmod(0o500 if p == distribution / fault.NODE else 0o400) Path(directory).chmod(0o500) - script = base / 'root.cjs'; script.write_text('const p=require("node:child_process").spawn(' + json.dumps(str(distribution / fault.NODE)) + ',["--require",' + json.dumps(str(distribution / fault.GUARD)) + ',' + json.dumps(str(distribution / 'pi-entry.cjs')) + '],{stdio:"ignore"});process.on("SIGTERM",()=>{if(p.exitCode!==null||p.signalCode!==null)process.exit(0);p.once("exit",()=>process.exit(0));p.kill("SIGTERM")});setInterval(()=>{},1000);') + launch = json.dumps(str(distribution / fault.NODE)) + setup, stdio, release = '', '"ignore"', '' + if descriptor is not None: + # Production nativeBootstrap keeps its executable descriptor + # at child FD 3, or FD 7 when lifetime/credential fences exist. + setup = 'const fd=require("node:fs").openSync(' + launch + ',"r");' + launch = json.dumps(f'/proc/self/fd/{descriptor}') + stdio = '[' + ','.join(['"ignore"'] * descriptor + ['fd']) + ']' + release = 'require("node:fs").closeSync(fd);' + script = base / 'root.cjs'; script.write_text(setup + 'const p=require("node:child_process").spawn(' + launch + ',["--require",' + json.dumps(str(distribution / fault.GUARD)) + ',' + json.dumps(str(distribution / 'pi-entry.cjs')) + '],{stdio:' + stdio + '});' + release + 'process.on("SIGTERM",()=>{if(p.exitCode!==null||p.signalCode!==null)process.exit(0);p.once("exit",()=>process.exit(0));p.kill("SIGTERM")});setInterval(()=>{},1000);') check_cancelled() root_child = subprocess.Popen([str(runtime / 'bin/paperclip-runnerd'), str(script), '--run-id', 'fixture', '--environment-lease-id', 'workspace-id', '--lifecycle-mode', 'per_turn', '--state-dir', str(runtime / 'sessions' / ('a' * 64) / 'runner')], cwd=workspace, env={'PATH': '/usr/bin:/bin'}, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) try: owned_fds.append(os.pidfd_open(root_child.pid)) @@ -143,7 +284,7 @@ class PidfdTests(unittest.TestCase): self.assertTrue(ready.exists()); target = json.loads(ready.read_text())['pid'] self.assertEqual(fault.argv(target), ['pi'], 'real Node process.title must overwrite argv') boot = Path('/proc/sys/kernel/random/boot_id').read_text().strip() - config = {'root': fault.proc(root_child.pid, boot), 'binding': {'remoteCwd': str(workspace), 'runId': 'fixture'}, 'runtimeEnvironmentLeaseId': 'workspace-id', 'runnerdSha256': 'sha256:' + fault.digest((runtime / 'bin/paperclip-runnerd').read_bytes()), 'closureSha256': fault.digest(closure)} + config = {'root': fault.proc(root_child.pid, boot), 'binding': {'remoteCwd': str(workspace), 'runId': 'fixture'}, 'runtimeEnvironmentLeaseId': 'workspace-id', 'runnerdSha256': 'sha256:' + fault.digest((runtime / 'bin/paperclip-runnerd').read_bytes()), 'closureSha256': fault.digest(json.dumps(entries, separators=(',', ':'), ensure_ascii=False).encode())} with patch.object(fault, 'PACK', str(pack)): admitted = fault.inspect(config) self.assertEqual(admitted['target']['pid'], target) diff --git a/tests/runner-e2e/pi-provider-fault.test.ts b/tests/runner-e2e/pi-provider-fault.test.ts index c9096b14a0..4c059c2cf5 100644 --- a/tests/runner-e2e/pi-provider-fault.test.ts +++ b/tests/runner-e2e/pi-provider-fault.test.ts @@ -15,8 +15,8 @@ it("calibrates the exact Pi fault helper with metadata negatives and native Linu expect(result.error).toBeUndefined(); expect(result.signal).toBeNull(); expect(result.status, result.stderr).toBe(0); - expect(result.stderr).toContain("Ran 9 tests"); + expect(result.stderr).toContain("Ran 21 tests"); if (process.platform === "linux") expect(result.stderr).not.toContain("skipped"); - else expect(result.stderr).toContain("skipped=1"); + else expect(result.stderr).toContain("skipped=3"); console.info(result.stderr.trim()); }, 30_000); diff --git a/tests/runner-e2e/playwright.config.ts b/tests/runner-e2e/playwright.config.ts index 8fd8441e84..195c81536e 100644 --- a/tests/runner-e2e/playwright.config.ts +++ b/tests/runner-e2e/playwright.config.ts @@ -45,7 +45,7 @@ const repositoryRoot = path.resolve(import.meta.dirname, "../.."); export default defineConfig({ testDir: ".", - testMatch: "runner.spec.ts", + testMatch: process.env.PAPERCLIP_RUNNER_E2E_INSTALLED_STARTUP_ONLY === "1" ? "installed-startup.spec.ts" : "runner.spec.ts", timeout: Number(process.env.PAPERCLIP_RUNNER_E2E_TEST_TIMEOUT_MS ?? 600_000), expect: { timeout: 30_000 }, fullyParallel: false, diff --git a/tests/runner-e2e/prerequisites.ts b/tests/runner-e2e/prerequisites.ts index 227318fbf5..e9d5430a15 100644 --- a/tests/runner-e2e/prerequisites.ts +++ b/tests/runner-e2e/prerequisites.ts @@ -7,9 +7,8 @@ export const PENDING_PROFILE_PREREQUISITES = { "legacy-grok": "Grok identity/auth/skills/session/billing qualification is pending", } as const; -/** Pi is intentionally absent: no qualified model source exists for a valid profile. */ +/** Qualification fixtures use explicit models; there is no runtime model roster. */ 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. */ @@ -18,7 +17,7 @@ export function assertRemoteNativeEvidencePrerequisites( environment: NodeJS.ProcessEnv, ): void { if (!executions.some(execution => execution.environment.id === "daytona" - && ["cursor_native", "native_active_stop", "native_provider_loss"].includes(execution.task.flow))) return; + && ["cursor_native", "pi_native", "pi_controls", "copilot_protection", "native_active_stop", "native_provider_loss"].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(", ")}`); diff --git a/tests/runner-e2e/process-tree-owner.test.ts b/tests/runner-e2e/process-tree-owner.test.ts index b3fa3b4543..17dcafc07e 100644 --- a/tests/runner-e2e/process-tree-owner.test.ts +++ b/tests/runner-e2e/process-tree-owner.test.ts @@ -27,6 +27,38 @@ it.skipIf(process.platform === "win32")("revalidates PID/start before signaling } finally { f.owner.stopObserving(); } }); +it.skipIf(process.platform === "win32")("admits replacement group members only through a still-owned ancestor", async () => { + const f = fixture([row(100, process.pid, 100), row(200, 100, 200)]); + try { + await f.owner.observe(); + // The short-lived group leader exited between polls. Its new member is + // independently owned through the original, still-live controller. + f.replace([row(100, process.pid, 100), row(300, 100, 200)]); + await f.owner.signal("SIGKILL"); + expect(f.signals).toEqual([[200, "SIGKILL"], [100, "SIGKILL"]]); + } finally { f.owner.stopObserving(); } +}); + +it.skipIf(process.platform === "win32")("refuses replacement groups containing any unowned live member", 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(300, 100, 200), row(400, 1, 200)]); + await expect(f.owner.signal("SIGKILL")).rejects.toThrow("identity became uncertain"); + expect(f.signals).toEqual([]); + } finally { f.owner.stopObserving(); } +}); + +it.skipIf(process.platform === "win32")("does not use a recycled controller as a replacement member's ownership anchor", 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, "recycled-controller"), row(300, 100, 200)]); + 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 { diff --git a/tests/runner-e2e/process-tree-owner.ts b/tests/runner-e2e/process-tree-owner.ts index a5da0dbbd7..ba5dbb0fdf 100644 --- a/tests/runner-e2e/process-tree-owner.ts +++ b/tests/runner-e2e/process-tree-owner.ts @@ -5,6 +5,29 @@ import { type ObservedProcessGroup, type ProcessObservation, } from "./process-tree.js"; +/** Supplied by the company-scoped run API, never by process-name discovery. */ +export interface RestartRunnerIdentity { + processPid: number; + processGroupId: number; + processStartedAt: string; +} +export function parseRestartRunnerIdentity(value: unknown): RestartRunnerIdentity { + const v = value as RestartRunnerIdentity | null; + if (!v || !Number.isSafeInteger(v.processPid) || v.processPid <= 1 + || v.processGroupId !== v.processPid || typeof v.processStartedAt !== "string" + || !Number.isFinite(Date.parse(v.processStartedAt))) throw new Error("Invalid restart runner identity"); + return { processPid: v.processPid, processGroupId: v.processGroupId, processStartedAt: v.processStartedAt }; +} + +/** Linux run receipts retain milliseconds; ps lstart retains whole seconds. */ +export function restartProcessStartMatches(observed: string, expected: string, platform = process.platform): boolean { + const actualTime = Date.parse(observed), expectedTime = Date.parse(expected); + if (!Number.isFinite(actualTime) || !Number.isFinite(expectedTime)) return false; + return platform === "linux" + ? Math.floor(actualTime / 1000) === Math.floor(expectedTime / 1000) + : actualTime === expectedTime; +} + export function createProcessTreeOwner(root: ChildProcess, options: { readTable?: () => Promise; signalGroup?: (pid: number, signal: NodeJS.Signals) => void; @@ -12,6 +35,8 @@ export function createProcessTreeOwner(root: ChildProcess, options: { let groups: ObservedProcessGroup[] = []; let table: ProcessObservation[] = []; let rootStarted: string | undefined; + let preserved: ObservedProcessGroup[] = []; + let restartRunner: ProcessObservation | undefined; let observing: Promise | undefined; const covered = new Set(); const readTable = options.readTable ?? readProcessTable; @@ -30,10 +55,20 @@ export function createProcessTreeOwner(root: ChildProcess, options: { 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 trustedAnchors = retained.flatMap(group => group.members + .filter(member => next.some(row => row.pid === member.pid && row.started === member.started + && row.processGroupId === group.processGroupId)).map(member => member.pid)); + if (rootRow && rootStarted === rootRow.started) trustedAnchors.push(rootRow.pid); + const ownedNow = new Set(trustedAnchors.flatMap(pid => + observeDescendantProcessTree(next, pid).members.map(member => member.process.pid))); + // A short-lived leader can exit between polls while its replacement + // remains a descendant of a separately validated owner. Admit that + // ancestry, never a recycled numeric group or a mixed ownership group. 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) + const uncertain = lost.filter(group => next.some(row => row.processGroupId === group.processGroupId + && running(row) && !ownedNow.has(row.pid))); + if (uncertain.length) throw new Error(`Owned process group identity became uncertain: ${uncertain.map(group => group.processGroupId).join(",")}`); + const refreshed = refreshContinuouslyLiveProcessGroups([...retained, ...lost], 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); @@ -47,6 +82,14 @@ export function createProcessTreeOwner(root: ChildProcess, options: { } } groups = [...byGroup.values()]; + // Follow only continuously owned members of the admitted daemon tree. + // Orphaned descendants stay owned, but unrelated controller children do not. + const retainedPreserved = refreshContinuouslyLiveProcessGroups(revalidateObservedProcessGroups(preserved, next), next); + const preservedIds = new Set(retainedPreserved.map(group => group.processGroupId)); + for (const member of retainedPreserved.flatMap(group => group.members)) { + for (const group of observeDescendantProcessTree(next, member.pid).groups) preservedIds.add(group.processGroupId); + } + preserved = groups.filter(group => preservedIds.has(group.processGroupId)); table = next; })().finally(() => { observing = undefined; }); return observing; @@ -60,6 +103,33 @@ export function createProcessTreeOwner(root: ChildProcess, options: { function liveGroups() { return groups.filter(group => table.some(row => row.processGroupId === group.processGroupId && running(row))); } + async function preserveRunnerForRestart(identity: RestartRunnerIdentity) { + const expected = parseRestartRunnerIdentity(identity); + await observe(); + if (restartRunner || exited()) throw new Error("Restart runner admission must precede controller exit"); + const tree = observeDescendantProcessTree(table, root.pid!); + const candidate = tree.members.map(member => member.process).find(row => row.pid === expected.processPid); + if (!candidate || !running(candidate) || candidate.kind !== "paperclip-runnerd" + || candidate.processGroupId !== expected.processGroupId + || !restartProcessStartMatches(candidate.started, expected.processStartedAt) + || candidate.processGroupId === root.pid) throw new Error("Restart runner is not the exact owned durable daemon"); + const subtree = observeDescendantProcessTree(table, candidate.pid); + const members = new Set(subtree.members.map(member => member.process.pid)); + const ids = new Set(subtree.groups.map(group => group.processGroupId)); + if (table.some(row => ids.has(row.processGroupId) && !members.has(row.pid) && running(row))) { + throw new Error("Restart runner shares a process group with an unrelated process"); + } + restartRunner = { ...candidate }; + preserved = groups.filter(group => ids.has(group.processGroupId)); + } + function assertRestartRunnerAlive() { + if (!restartRunner || !table.some(row => row.pid === restartRunner!.pid && row.started === restartRunner!.started + && row.processGroupId === restartRunner!.processGroupId && running(row))) throw new Error("Admitted restart runner did not survive controller restart"); + } + function restartCleanupGroups() { + const ids = new Set(preserved.map(group => group.processGroupId)); + return new Set(liveGroups().filter(group => !ids.has(group.processGroupId)).map(group => group.processGroupId)); + } 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"); @@ -118,6 +188,7 @@ export function createProcessTreeOwner(root: ChildProcess, options: { graceComplete = selected.size > 0 && delivered.length === selected.size; } return { observe, signal, liveGroups, gracefulRoots, signalGracefully, + preserveRunnerForRestart, assertRestartRunnerAlive, restartCleanupGroups, directGraceDelivered: () => directGraceDelivered, stopObserving: () => { if (timer) clearInterval(timer); } }; } diff --git a/tests/runner-e2e/process-tree.ts b/tests/runner-e2e/process-tree.ts index 1ab4ebcddc..7a21642d9c 100644 --- a/tests/runner-e2e/process-tree.ts +++ b/tests/runner-e2e/process-tree.ts @@ -159,6 +159,14 @@ const diagnosticProcessKinds = new Set([ "tsx", ]); +export function diagnosticProcessKind(command: string, platform: NodeJS.Platform = process.platform) { + const name = path.basename(command); + // Linux comm is limited to 15 bytes. This is a diagnostic role only; + // preservation still requires the trusted run PID/start/group and ancestry. + if (platform === "linux" && name === "paperclip-runne") return "paperclip-runnerd"; + return diagnosticProcessKinds.has(name) ? name : "other"; +} + export async function readProcessTable(startInspector?: () => ChildProcess): Promise { if (process.platform === "win32") { return null; @@ -212,8 +220,7 @@ export async function readProcessTable(startInspector?: () => ChildProcess): Pro // 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"; + const kind = diagnosticProcessKind(match[6]!); return { pid: Number(match[1]), parentPid: Number(match[2]), diff --git a/tests/runner-e2e/remote-native-bootstrap.test.ts b/tests/runner-e2e/remote-native-bootstrap.test.ts index 007957af88..c4fddd65c8 100644 --- a/tests/runner-e2e/remote-native-bootstrap.test.ts +++ b/tests/runner-e2e/remote-native-bootstrap.test.ts @@ -1,4 +1,5 @@ import { explicitlyRequestsFileOutput } from "../../server/src/services/native-runtime/native-deliverable-feedback.js"; +import { copilotProtectionTasks } from "./copilot-protection-tasks.js"; import type { APIRequestContext, APIResponse } from "@playwright/test"; import { afterEach, expect, it, vi } from "vitest"; import { classifyFailure } from "./failure-classifier.js"; @@ -11,7 +12,7 @@ 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 run = { id: "run", companyId: "company", agentId: "agent", status: "running", executionStage: "preparing", runtimeMode: "native", nativePhase: "provider_running" }; 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 = { @@ -48,6 +49,19 @@ it("keeps negated bootstrap instructions out of the production file-delivery con expect(explicitlyRequestsFileOutput(`${prompt}\nCreate a downloadable report.txt file.`)).toBe(true); }); +it("keeps async completion immediate with a private sentinel and no delivery wait", () => { + const task = copilotProtectionTasks.find(task => task.id === "attached-async-settlement")!; + const action = task.buildPrompt("nonce"); + expect(explicitlyRequestsFileOutput(action)).toBe(false); + expect(explicitlyRequestsFileOutput(`${harness().bootstrap.prompt("nonce")}\n${action}`)).toBe(false); + expect(action).toContain("attempt to finish immediately without calling read_bash or another waiting tool"); + expect(action).toContain("Do not modify fixture code, manufacture its result, or start another command."); + expect(action).toContain("private diagnostic sentinel, not a requested file deliverable"); + expect(action).toContain("Do not publish it. Do not attach it."); + expect(explicitlyRequestsFileOutput(`${action}\nAttach a downloadable report.txt file.`)).toBe(true); + expect(action).toContain("evidence [], verification []"); + expect(action).not.toContain("register_deliverable"); +}); it("keeps the complete readiness/install reserve when a lease arrives at the admission boundary", async () => { vi.useFakeTimers(); vi.setSystemTime(0); @@ -106,6 +120,13 @@ it("waits through queued admission without publishing early", async () => { expect(count).toBe(2); expect(h.fixture.publishAction).toHaveBeenCalledTimes(1); }); +it.each(["provider_running", "observed"])("admits a native %s run while its legacy stage stays preparing", async nativePhase => { + const h = harness(); h.bootstrap.prompt("native-ready"); h.run.nativePhase = nativePhase; + await expect(h.bootstrap.bindAndRelease(h.request)).resolves.toBe(h.fixture); + expect(h.run.executionStage).toBe("preparing"); + expect(h.bind).toHaveBeenCalledTimes(1); 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"); diff --git a/tests/runner-e2e/remote-native-bootstrap.ts b/tests/runner-e2e/remote-native-bootstrap.ts index c750ae2953..7edcf68070 100644 --- a/tests/runner-e2e/remote-native-bootstrap.ts +++ b/tests/runner-e2e/remote-native-bootstrap.ts @@ -173,6 +173,8 @@ export function createRemoteNativeBootstrap(input: { if (lastReadError !== undefined) throw lastReadError; return state; }, + // Native runs can retain the legacy "preparing" stage. The observer's + // runtime-ready RPC proves the actual pinned daemon before installation. accept: state => !state.rejection && state.owned && state.run?.status === "running" && Boolean(state.lease) && Date.now() < admissionDeadlineAt, reject: state => state.rejection, diff --git a/tests/runner-e2e/remote-native-fixtures.test.ts b/tests/runner-e2e/remote-native-fixtures.test.ts index a54216695b..116d7a8d37 100644 --- a/tests/runner-e2e/remote-native-fixtures.test.ts +++ b/tests/runner-e2e/remote-native-fixtures.test.ts @@ -7,8 +7,9 @@ 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 { REMOTE_FIXTURE_MIN_SETUP_BUDGET_MS, bindRemoteNativeFixture, validatePiProviderDeathReceipt, createRemoteTargetWatch, isRemoteRunRoot, parseRemoteProcStat, remoteProcEntryDisappeared, remoteNativeFixtureDiagnostics, remoteNativeIncompleteTerminalEvidence, type RemoteNativeFixtureOptions, type RemoteNativeSnapshot } from "./remote-native-fixtures.js"; +import { PI_DISTRIBUTION_CLOSURE_SHA256 } from "../../packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.js"; import { createRemoteNativeBootstrap } from "./remote-native-bootstrap.js"; const hash = (s: string) => `sha256:${createHash("sha256").update(s).digest("hex")}`; @@ -52,6 +53,19 @@ function harness() { setLease(value: Record) { lease = value; }, lease: () => lease, override(fn: typeof override) { override = fn; } }; } +describe("remote process exit during proc reads", () => { + it.each(["ENOENT", "ESRCH"])("accepts confirmed %s process absence", code => { + const confirm = vi.fn(); + expect(remoteProcEntryDisappeared({ code }, confirm)).toBe(true); + expect(confirm).not.toHaveBeenCalled(); + expect(remoteProcEntryDisappeared({ code: "EIO" }, () => { throw { code }; })).toBe(true); + }); + it.each(["EIO", "EACCES", undefined])("rejects an unreadable existing process (%s)", code => { + expect(remoteProcEntryDisappeared({ code }, () => ({ isDirectory: () => true }))).toBe(false); + expect(remoteProcEntryDisappeared({ code }, () => { throw { code: "EACCES" }; })).toBe(false); + }); +}); + 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" }); @@ -232,19 +246,63 @@ describe("remote native lease admission", () => { 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("reports only closed terminal failure reasons without weakening retirement proof", async () => { + it("retains a validated incomplete terminal receipt without raw file or RPC content", async () => { const h = harness(), f = await bindRemoteNativeFixture(h.options); await f.publishAction("action.txt", "task"); - const end = { ...structuredClone(h.current), complete: false, - processes: { ...h.current.processes, live: [] }, files: {}, - failureCodes: ["process_pid_reused", "secret-provider-payload", "process_pid_reused", { arbitrary: "payload" }] }; + const end = { ...structuredClone(h.current), complete: false, watcher: { ...h.current.watcher, complete: false }, + processes: { ...h.current.processes, live: [] }, files: { "private.txt": "PRIVATE RPC CONTENT" }, untrustedDump: "PRIVATE RPC CONTENT" }; h.resolveTerminal(end); const error = await f.finish().catch(error => error); - expect(error.message).toContain("terminal_evidence_incomplete:published=true,complete=false"); - expect(error.message).toContain(":process_pid_reused"); - expect(error.message).not.toContain("secret-provider-payload"); - expect(error.message).not.toContain("arbitrary"); - expect(remoteNativeFixtureDiagnostics(error)).toEqual([{ phase: "wait", code: "terminal_evidence_incomplete" }]); + expect(error.message).toBe("remote_native_fixture:terminal_evidence_incomplete"); + const retained = remoteNativeIncompleteTerminalEvidence(error)!; + expect(retained.complete).toBe(false); expect(retained.watcher.complete).toBe(false); + expect(retained.processes.liveCount).toBe(0); + expect(retained.incompleteReasons).toEqual([]); + expect(JSON.stringify(retained)).not.toContain("PRIVATE RPC CONTENT"); + retained.watcher.complete = true; + expect(remoteNativeIncompleteTerminalEvidence(error)!.watcher.complete).toBe(false); + expect(remoteNativeIncompleteTerminalEvidence(new Error("PRIVATE RPC CONTENT"))).toBeUndefined(); + }); + it("retains only closed incompleteness reasons and rejects raw diagnostics", async () => { + const h = harness(), f = await bindRemoteNativeFixture(h.options); await f.publishAction("action.txt", "task"); + h.resolveTerminal({ ...structuredClone(h.current), complete: false, incompleteReasons: ["unwatched_directory"], + processes: { ...h.current.processes, live: [] }, files: {} }); + const error = await f.finish().catch(error => error); + expect(remoteNativeIncompleteTerminalEvidence(error)?.incompleteReasons).toEqual(["unwatched_directory"]); + await f.close(); + const bad = harness(), other = await bindRemoteNativeFixture(bad.options); await other.publishAction("action.txt", "task"); + bad.resolveTerminal({ ...structuredClone(bad.current), complete: false, incompleteReasons: ["PRIVATE RAW PATH"], + processes: { ...bad.current.processes, live: [] }, files: {} }); + const rejected = await other.finish().catch(error => error); + expect(rejected.message).toContain("incomplete_reasons_shape"); + expect(remoteNativeIncompleteTerminalEvidence(rejected)).toBeUndefined(); + await other.close(); + }); + it("keeps incomplete evidence failed while closing a retired observer after public lease deletion", async () => { + const h = harness(), f = await bindRemoteNativeFixture(h.options); + await f.publishAction("action.txt", "task"); + h.resolveTerminal({ ...structuredClone(h.current), complete: false, incompleteReasons: ["unwatched_directory"], + watcher: { ...h.current.watcher, complete: false }, processes: { ...h.current.processes, live: [] }, files: {} }); + await expect(f.finish()).rejects.toThrow("terminal_evidence_incomplete"); + h.setLease({ ...h.lease(), status: "released", releasedAt: "2026-10-06T01:00:00Z" }); + const before = h.calls.length; + await expect(f.close()).resolves.toBeUndefined(); + expect(h.calls).toHaveLength(before); + await expect(f.finish()).rejects.toThrow("terminal_evidence_incomplete"); + await expect(f.readFile("result.txt")).rejects.toThrow("unregistered_read"); + }); + it.each(["live", "missing-root", "invalid-shape", "unknown-cause", "reused-process", "attached-live"])("does not treat %s evidence as retired cleanup", async kind => { + const h = harness(), f = await bindRemoteNativeFixture(h.options); + await f.publishAction("action.txt", "task"); + const end: any = { ...structuredClone(h.current), complete: false, processes: { ...h.current.processes, live: [] }, files: {} }; + if (kind === "live") end.processes.live = [21]; + if (kind === "missing-root") end.processes = { captured: false, root: null, journal: [], live: [] }; + if (kind === "invalid-shape") end.observedAtMs = "invalid"; + if (kind === "reused-process") end.incompleteReasons = ["process_identity_reused"]; + if (kind === "attached-live") end.incompleteReasons = ["attached_process_live"]; + h.resolveTerminal(end); await expect(f.finish()).rejects.toThrow(); + h.setLease({ ...h.lease(), status: "released", releasedAt: "2026-10-06T01:00:00Z" }); + await expect(f.close()).rejects.toThrow("lease_scope"); }); it("keeps a fixture-owned cross-root sentinel distinct from workspace targets", async () => { const h = harness(); h.options.crossRoot = { initialText: "outside sentinel" }; @@ -334,7 +392,7 @@ describe("cell deadline and cleanup bounds", () => { }); describe("independent filesystem and process observations", () => { - it.skipIf(process.platform !== "linux")("retains transient create/delete events and detects same-path parent replacement", async () => { + it("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")); @@ -378,7 +436,7 @@ describe("actual generated observer state machine", () => { 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 symbolicLinks = new Set(), directoryInodes = new Map(); 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>(); @@ -402,10 +460,10 @@ describe("actual generated observer state machine", () => { 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 }; + return { dev: 1n, ino: directoryInodes.get(path) ?? (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 []; }, + readdirSync(path: string) { if (path === "/proc") return [...proc.keys()].map(String); if (path.startsWith("/workspace/.paperclip-runtime/paperclip-runner")) throw new Error("excluded runtime must not be traversed"); return [...new Set([...files.keys(), ...directories, ...symbolicLinks].filter(p => p.startsWith(path + "/") && !p.slice(path.length + 1).includes("/")).map(p => p.slice(path.length + 1)))]; }, 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; } }); @@ -432,8 +490,9 @@ describe("actual generated observer state machine", () => { close: vi.fn(() => listeners.clear()), }; const net = { createServer(fn: (socket: any) => void) { handlers.push(fn); return server; } }; + const faultSpawn = vi.fn(() => ({ status: 0, stdout: JSON.stringify(piFaultReceipt()) })); 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"); }, + require(name: string) { if (name === "node:fs") return fs; if (name === "node:net") return net; if (name === "node:child_process") return { spawnSync: faultSpawn, 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(), @@ -445,8 +504,56 @@ describe("actual generated observer state machine", () => { 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; } }; + return { request, proc, files, watches, fs, intervals, timers, config, handlers, children, faultSpawn, symbolicLinks, directoryInodes, context, server, directories, listeners, install: h.calls.find(c => c.request.op === "install")!, replaceRuntimeRoot() { runtimeInode = 999n; } }; } + it("keeps existing watched directory notifications complete without losing nested mutations", async () => { + const o = await observerHarness(true); + o.directories.add(o.config.root); o.files.set(`${o.config.root}/observer.cjs`, Buffer.from(o.install.request.source)); + o.directories.add("/workspace/existing"); + new Script(o.install.request.source).runInNewContext(o.context); + // Linux emits a parent notification when chmod changes a child's directory + // attributes. Its inode and registered recursive watch remain unchanged. + for (const w of o.watches) if (w.path === "/workspace") w.callback("change", "existing"); + expect(o.request("snapshot")[0].result.complete).toBe(true); + o.fs.writeFileSync("/workspace/existing/transient.txt", "changed", { flag: "wx" }); + o.files.delete("/workspace/existing/transient.txt"); + o.watches.find(w => w.path === "/workspace/existing")!.callback("rename", "transient.txt"); + const final = o.request("snapshot")[0].result; + expect(final.complete).toBe(true); expect(final.watcher.workspaceMutationCount).toBe(3); + expect(final.workspace["existing/transient.txt"]).toBeUndefined(); + }); + it.each(["new", "replacement", "symlink", "unknown"])("rejects %s directory notifications", async variant => { + const o = await observerHarness(true); + o.directories.add(o.config.root); o.files.set(`${o.config.root}/observer.cjs`, Buffer.from(o.install.request.source)); + if (variant !== "new") o.directories.add("/workspace/existing"); + new Script(o.install.request.source).runInNewContext(o.context); + if (variant === "new") o.directories.add("/workspace/existing"); + if (variant === "replacement") o.directoryInodes.set("/workspace/existing", 999n); + if (variant === "symlink") o.symbolicLinks.add("/workspace/existing"); + for (const w of o.watches) if (w.path === "/workspace") w.callback("change", variant === "unknown" ? null : "existing"); + const reply = o.request("snapshot")[0]; + expect(reply.ok && reply.result.complete).toBe(false); + }); + it("executes the actual observer fault phase once with a closed environment and exact identity", async () => { + for (const published of [false, true]) { + const rejected = await observerHarness(); + if (published) rejected.request("publish", { path: "action.txt", text: "ask native input" }); + expect(rejected.request("pi-provider-death", { runtimeEnvironmentLeaseId: published ? "foreign" : "workspace-id" })[0].ok).toBe(false); + expect(rejected.faultSpawn).not.toHaveBeenCalled(); + } + const o = await observerHarness(); + expect(o.request("snapshot")[0].ok).toBe(true); + expect(o.request("publish", { path: "action.txt", text: "ask native input" })[0].ok).toBe(true); + expect(o.request("pi-provider-death", { runtimeEnvironmentLeaseId: "workspace-id" })[0]).toEqual({ ok: true, result: piFaultReceipt() }); + expect(o.faultSpawn).toHaveBeenCalledTimes(1); + const [program, args, options] = o.faultSpawn.mock.calls[0] as unknown as [string, string[], Record]; + expect(program).toBe("/usr/bin/python3"); expect(args.slice(0, 2)).toEqual(["-I", "-c"]); + expect(args[2]).toContain("signal.pidfd_send_signal"); + expect(JSON.parse(Buffer.from(args[3]!, "base64").toString())).toEqual({ root, binding, runtimeEnvironmentLeaseId: "workspace-id", runnerdSha256: hash("runnerd"), closureSha256: PI_DISTRIBUTION_CLOSURE_SHA256["linux-x64"] }); + expect(options).toMatchObject({ env: { PATH: "/usr/bin:/bin" }, timeout: 8000, maxBuffer: 32768 }); + expect(o.request("pi-provider-death", { runtimeEnvironmentLeaseId: "workspace-id" })[0].ok).toBe(false); + expect(o.faultSpawn).toHaveBeenCalledTimes(1); + }); 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("'\\''", "'"); @@ -689,7 +796,7 @@ describe("actual generated observer state machine", () => { 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"); - expect(wait[0].result.failureCodes).toContain("process_pid_reused"); + expect(wait[0].result.incompleteReasons).toContain("process_identity_reused"); }); it("counts transient workspace create/delete and rejects changed setup-file bytes", async () => { const o = await observerHarness(); o.request("snapshot"); @@ -707,3 +814,41 @@ describe("actual generated observer state machine", () => { expect(other.request("snapshot")[0].ok).toBe(false); }); }); + +function piFaultReceipt() { + const parent = { pid: 22, ppid: 21, startTicks: "101", bootId }, target = { pid: 23, ppid: 22, startTicks: "102", bootId }; + return { schema: "paperclip.e2e.pi-provider-death.v1", binding, runtimeEnvironmentLeaseId: "workspace-id", target, ancestry: [target, parent, root], + nodeSha256: hash("node"), entrypointSha256: hash("entry"), closureSha256: PI_DISTRIBUTION_CLOSURE_SHA256["linux-x64"], + entrypointAttribution: "pinned_wrapper_parent", originalChildArgvAvailable: false, observedChildTitle: "pi", signalled: true, signal: "SIGKILL", targetKind: "pi_native_child", workerSignalled: false }; +} +describe("exact Pi-child fault capability", () => { + it("validates ancestry and honest parent-attested entrypoint identity", () => { + const receipt = piFaultReceipt(); + expect(validatePiProviderDeathReceipt(receipt, binding, root, "workspace-id")).toEqual(receipt); + for (const patch of [{ workerSignalled: true }, { target: root }, { originalChildArgvAvailable: true }, { entrypointAttribution: "process_title" }, + { binding: { ...binding, runId: "foreign" } }, { runtimeEnvironmentLeaseId: "foreign" }, { closureSha256: "0".repeat(64) }, + { ancestry: [receipt.target, { ...receipt.ancestry[1], startTicks: "" }, root] }, { ancestry: [receipt.target, root] }, + { ancestry: [receipt.target, { ...receipt.ancestry[1], ppid: 999 }, root] }, { ancestry: [receipt.target, receipt.ancestry[1], { ...root, startTicks: "999" }] }]) { + expect(() => validatePiProviderDeathReceipt({ ...receipt, ...patch }, binding, root, "workspace-id")).toThrow(); + } + }); + it("requires published action and consumes the one-shot capability even on RPC failure", async () => { + const h = harness(), fixture = await bindRemoteNativeFixture(h.options); + await expect(fixture.terminatePiProvider!("workspace-id")).rejects.toThrow(); + await fixture.publishAction("action.txt", "Ask the native question"); + h.override(request => request.op === "pi-provider-death" ? { exitCode: 0, result: JSON.stringify({ ok: false, error: "pi_provider_identity_or_signal_failed" }) } : undefined); + await expect(fixture.terminatePiProvider!("workspace-id")).rejects.toThrow(); + await expect(fixture.terminatePiProvider!("workspace-id")).rejects.toThrow(); + expect(h.calls.filter(call => call.request.op === "pi-provider-death")).toHaveLength(1); + await fixture.close(); + }); + it("returns only the exact reviewed fault receipt and never signals the worker", async () => { + const h = harness(), fixture = await bindRemoteNativeFixture(h.options); + await fixture.publishAction("action.txt", "Ask the native question"); + h.override(request => request.op === "pi-provider-death" ? { exitCode: 0, result: JSON.stringify({ ok: true, result: piFaultReceipt() }) } : undefined); + expect(await fixture.terminatePiProvider!("workspace-id")).toEqual(piFaultReceipt()); + await expect(fixture.terminatePiProvider!("workspace-id")).rejects.toThrow(); + expect(h.calls.filter(call => call.request.op === "pi-provider-death")).toHaveLength(1); + await fixture.close(); + }); +}); diff --git a/tests/runner-e2e/remote-native-fixtures.ts b/tests/runner-e2e/remote-native-fixtures.ts index 5af1e08c63..0160f5c2c6 100644 --- a/tests/runner-e2e/remote-native-fixtures.ts +++ b/tests/runner-e2e/remote-native-fixtures.ts @@ -1,7 +1,10 @@ import { createHash, randomBytes } from "node:crypto"; -import { watch, lstatSync } from "node:fs"; +import { watch, lstatSync, readFileSync } from "node:fs"; import { posix } from "node:path"; +import { PI_DISTRIBUTION_CLOSURE_SHA256 } from "../../packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.js"; +const PI_FAULT_SOURCE = readFileSync(new URL("./pi-provider-fault.py", import.meta.url), "utf8"); + 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; @@ -30,8 +33,8 @@ function relative(value: string): string { } // 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", "inject-loss"] 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", "terminal_evidence_incomplete"] as const; +const RPC_PHASES = ["runtime-ready", "install", "wait", "close", "arm", "publish", "snapshot", "attached", "read", "pi-provider-death"] 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", "pi_provider_identity_or_signal_failed"] as const; type RpcPhase = typeof RPC_PHASES[number]; type RpcCode = typeof RPC_CODES[number]; interface RpcDiagnostic { phase: RpcPhase; code: RpcCode } @@ -48,12 +51,16 @@ export function remoteNativeFixtureDiagnostics(error: unknown): RpcDiagnostic[] 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 } +const INCOMPLETE_REASONS = ["multiple_run_roots", "run_root_changed", "process_identity_reused", "process_sample_failed", "process_read_io", "process_stat_shape", "process_runtime_binding", "process_terminal_seal_failed", "unknown_watch_name", "setup_file_changed", "workspace_event_bound", "workspace_symlink", "unwatched_directory", "directory_identity_changed", "watch_stat_failed", "workspace_watch_error", "target_watch_incomplete", "receipt_byte_bound", "attached_process_live", "control_request_bound", "observer_ttl"] as const; +type IncompleteReason = typeof INCOMPLETE_REASONS[number]; +const FILESYSTEM_INCOMPLETE_REASONS: readonly IncompleteReason[] = ["unknown_watch_name", "setup_file_changed", "workspace_event_bound", "workspace_symlink", "unwatched_directory", "directory_identity_changed", "watch_stat_failed", "workspace_watch_error", "target_watch_incomplete"]; export interface RemoteNativeSnapshot { binding: RemoteNativeBinding; observedAtMs: number; observedMonotonicNs: string; receivedAtMs: number; complete: boolean; + incompleteReasons?: IncompleteReason[]; workspace: Record; targets: Record; watcher: { complete: boolean; targetMutationCount: number; workspaceMutationCount: number }; @@ -67,6 +74,26 @@ export interface RemoteNativeSnapshot { 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; } +class IncompleteRemoteTerminalEvidenceError extends Error { + constructor(readonly snapshot: RemoteNativeSnapshot) { + super("remote_native_fixture:terminal_evidence_incomplete"); + } +} +/** Retain only a shape-validated public receipt, without raw RPC text or files. */ +export function remoteNativeIncompleteTerminalEvidence(error: unknown) { + if (!(error instanceof IncompleteRemoteTerminalEvidenceError)) return undefined; + const row = error.snapshot, root = row.processes.root; + return { + observedAtMs: row.observedAtMs, receivedAtMs: row.receivedAtMs, complete: row.complete, + incompleteReasons: [...(row.incompleteReasons ?? [])], + setupPublished: row.setup.published, + watcher: { complete: row.watcher.complete, targetMutationCount: row.watcher.targetMutationCount, workspaceMutationCount: row.watcher.workspaceMutationCount }, + processes: { captured: row.processes.captured, liveCount: row.processes.live.length, journalCount: row.processes.journal.length, + root: root ? { pid: root.pid, ppid: root.ppid, startTicks: root.startTicks, bootId: root.bootId } : null }, + targets: Object.fromEntries(Object.entries(row.targets).map(([name, target]) => [name, + { absent: target.absent, sha256: target.sha256, mutationCount: target.mutationCount, complete: target.complete }])), + }; +} /** 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 { @@ -84,6 +111,17 @@ export function parseRemoteProcStat(pid: number, stat: string, bootId: string): 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 }; } +/** Linux proc files can fail while a process exits. Confirm absence separately; + * an unreadable process that still exists remains a sampling failure. */ +export function remoteProcEntryDisappeared(error: unknown, confirmPresence: () => unknown): boolean { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === "ENOENT" || code === "ESRCH") return true; + try { confirmPresence(); return false; } + catch (confirmation) { + const missing = (confirmation as NodeJS.ErrnoException)?.code; + return missing === "ENOENT" || missing === "ESRCH"; + } +} 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]) => { @@ -121,25 +159,25 @@ export function createRemoteTargetWatch(directory: string, name: string, io = { 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 parseStat=PARSE_STAT;const runRoot=RUN_ROOT;const vanished=PROC_ENTRY_DISAPPEARED;const watchTarget=WATCH_TARGET;const PI_FAULT_SOURCE=PI_FAULT_PROGRAM,PI_CLOSURE_PIN=PI_CLOSURE_DIGEST; 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(),failureCodes=new Set();let observedRootCount=0,publishedHash=null,finalReceipt=null,retiring=false; -function incomplete(code){complete=false;failureCodes.add(code)} +const incompleteReasons=new Set();function incomplete(reason){complete=false;incompleteReasons.add(reason)} +const journal=new Map(),sockets=new Set(),waiters=new Set(),armWaiters=new Set();let observedRootCount=0,publishedHash=null,finalReceipt=null,retiring=false,piFaultAttempted=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 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(vanished(e,()=>fs.lstatSync('/proc/'+x)))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} + 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(vanished(e,()=>fs.lstatSync('/proc/'+p.pid)))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)incomplete('ambiguous_run_root'); + if(candidates.length>1)incomplete('multiple_run_roots'); 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))incomplete('run_root_changed'); 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))incomplete('process_pid_reused'); + if(all.some(p=>journal.has(p.pid)&&journal.get(p.pid).startTicks!==p.startTicks))incomplete('process_identity_reused'); 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}; @@ -150,23 +188,35 @@ function guard(){const rs=fs.lstatSync(runtimeRoot,{bigint:true});if(!rs.isDirec 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,failureCodes:[...failureCodes],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}} +function snapshot(){guard();verifyWorkspaceWatchRoots();const processes=sample(),out={},reasons=new Set(incompleteReasons);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;if(!status.complete)reasons.add('target_watch_incomplete');total+=status.mutationCount}return {binding:config.binding,observedAtMs:Date.now(),observedMonotonicNs:process.hrtime.bigint().toString(),complete:complete&&watchComplete,incompleteReasons:[...reasons].sort(),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){incomplete('workspace_watch_unknown_entry');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)incomplete('setup_file_changed')}catch{incomplete('setup_file_unreadable')}return}workspaceMutationCount++;if(workspaceMutationCount>4096)incomplete('workspace_watch_entry_bound');try{if(fs.lstatSync(path.join(directory,String(name))).isDirectory())incomplete('workspace_watch_new_directory')}catch(e){if(e.code!=='ENOENT')incomplete('workspace_watch_read_failure')}});handle.on('error',()=>{incomplete('workspace_watch_io_failure')});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+'/')}} +// Existing directory attribute notifications do not create an observation gap: +// accept only the same inode with an already registered recursive watch. New +// directories and replacements remain incomplete because their writes can race +// watch installation. Every notification still counts as a workspace mutation. +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){incomplete('unknown_watch_name');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)incomplete('setup_file_changed')}catch{incomplete('setup_file_changed')}return}workspaceMutationCount++;if(workspaceMutationCount>4096)incomplete('workspace_event_bound');try{const changed=path.join(directory,String(name)),st=fs.lstatSync(changed,{bigint:true});if(st.isSymbolicLink())incomplete('workspace_symlink');else if(st.isDirectory()){const watched=directoryWatches.find(item=>item.directory===changed);if(!watched)incomplete('unwatched_directory');else if(st.dev!==watched.before.dev||st.ino!==watched.before.ino)incomplete('directory_identity_changed')}}catch(e){if(e.code!=='ENOENT')incomplete('watch_stat_failed')}});handle.on('error',()=>{incomplete('workspace_watch_error')});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){incomplete('receipt_output_bound');finalReceipt.complete=false;finalReceipt.failureCodes=[...failureCodes];finalReceipt.files={};}if(child&&child.exitCode===null&&child.signalCode===null){incomplete('attached_child_live');finalReceipt.complete=false;finalReceipt.failureCodes=[...failureCodes];}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{incomplete('terminal_snapshot_failed')}},100)}}catch{incomplete('process_sample_failed')}},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; +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.incompleteReasons.push('receipt_byte_bound');finalReceipt.files={};}if(child&&child.exitCode===null&&child.signalCode===null){finalReceipt.complete=false;finalReceipt.incompleteReasons.push('attached_process_live')};for(const socket of waiters)socket.end(JSON.stringify({ok:true,result:finalReceipt})+'\n');waiters.clear();setTimeout(()=>shutdown(null),250);} +function sampleFailure(e){incomplete('process_sample_failed');if(e?.code)incomplete('process_read_io');else if(['invalid_proc_identity','invalid_proc_fields'].includes(e?.message))incomplete('process_stat_shape');else if(['process_prp_identity_shape','process_runtime_binding','process_prp_identity_changed','runner_binary_identity'].includes(e?.message))incomplete('process_runtime_binding')} +const observer=setInterval(()=>{try{const p=sample();if(p.captured&&p.live.length===0&&!retiring){retiring=true;setTimeout(()=>{let end;try{end=sample()}catch(e){sampleFailure(e);return}if(end.live.length!==0){retiring=false;return}try{seal()}catch{incomplete('process_terminal_seal_failed')}},100)}}catch(e){sampleFailure(e)}},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();incomplete('control_request_bound');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==='pi-provider-death'){ + if(piFaultAttempted||sealed||!publishedHash||!root||!complete||typeof r.runtimeEnvironmentLeaseId!=='string'||r.runtimeEnvironmentLeaseId!==observedPrpEnvironmentLeaseId)throw Error('pi_provider_identity_or_signal_failed'); + piFaultAttempted=true;const before=sample();if(!before.captured||!before.live.includes(root.pid))throw Error('pi_provider_identity_or_signal_failed'); + const request={root:{pid:root.pid,ppid:root.ppid,startTicks:root.startTicks,bootId:root.bootId},binding:config.binding,runtimeEnvironmentLeaseId:r.runtimeEnvironmentLeaseId,runnerdSha256:config.runnerdSha256,closureSha256:PI_CLOSURE_PIN}; + const childResult=cp.spawnSync('/usr/bin/python3',['-I','-c',PI_FAULT_SOURCE,Buffer.from(JSON.stringify(request)).toString('base64')],{env:{PATH:'/usr/bin:/bin'},timeout:8000,maxBuffer:32768,encoding:'utf8'}); + if(childResult.status!==0||childResult.error)throw Error('pi_provider_identity_or_signal_failed');result=JSON.parse(childResult.stdout); + } 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'); @@ -186,12 +236,12 @@ const server=net.createServer(socket=>{sockets.add(socket);socket.on('close',()= 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(); +server.listen(path.join(config.root,'control.sock'));setTimeout(()=>{incomplete('observer_ttl');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)); + return OBSERVER.replace("PI_FAULT_PROGRAM", () => JSON.stringify(PI_FAULT_SOURCE)).replace("PI_CLOSURE_DIGEST", () => JSON.stringify(PI_DISTRIBUTION_CLOSURE_SHA256["linux-x64"])).replace("PARSE_STAT", () => parseRemoteProcStat.toString()).replace("RUN_ROOT", () => isRemoteRunRoot.toString()) + .replace("PROC_ENTRY_DISAPPEARED", () => remoteProcEntryDisappeared.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(); @@ -208,6 +258,26 @@ 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 PiProviderDeathReceipt { + schema: "paperclip.e2e.pi-provider-death.v1"; binding: RemoteNativeBinding; runtimeEnvironmentLeaseId: string; + target: RemoteProcessIdentity; ancestry: RemoteProcessIdentity[]; nodeSha256: string; entrypointSha256: string; closureSha256: string; + entrypointAttribution: "pinned_wrapper_parent"; originalChildArgvAvailable: false; observedChildTitle: "pi"; + signalled: true; signal: "SIGKILL"; targetKind: "pi_native_child"; workerSignalled: false; +} +export function validatePiProviderDeathReceipt(value: unknown, binding: RemoteNativeBinding, root: RemoteProcessIdentity, lease: string): PiProviderDeathReceipt { + const r = record(value), target = record(r.target), ancestry = Array.isArray(r.ancestry) ? r.ancestry.map(record) : []; + fail(r.schema === "paperclip.e2e.pi-provider-death.v1" && JSON.stringify(r.binding) === JSON.stringify(binding) + && r.entrypointAttribution === "pinned_wrapper_parent" && r.originalChildArgvAvailable === false && r.observedChildTitle === "pi" + && r.runtimeEnvironmentLeaseId === lease && r.signalled === true && r.signal === "SIGKILL" && r.targetKind === "pi_native_child" && r.workerSignalled === false + && sha(r.nodeSha256) && sha(r.entrypointSha256) && r.closureSha256 === PI_DISTRIBUTION_CLOSURE_SHA256["linux-x64"], "pi_fault_receipt"); + fail(ancestry.length >= 3 && ancestry.length <= 64 && new Set(ancestry.map(p => p.pid)).size === ancestry.length + && ancestry.every(p => Number.isSafeInteger(p.pid) && Number(p.pid) > 1 && Number.isSafeInteger(p.ppid) && Number(p.ppid) > 0 + && typeof p.startTicks === "string" && /^\d+$/u.test(p.startTicks) && p.bootId === root.bootId) + && ["pid", "ppid", "startTicks", "bootId"].every(k => ancestry[0]?.[k] === target[k] && ancestry.at(-1)?.[k] === record(root)[k]) + && ancestry.slice(0, -1).every((p, i) => p.ppid === ancestry[i + 1]?.pid) && target.pid !== root.pid, "pi_fault_ancestry"); + return value as PiProviderDeathReceipt; +} + export interface RemoteNativeFixture { readonly binding: RemoteNativeBinding; readonly remoteCwd: string; @@ -216,9 +286,11 @@ export interface RemoteNativeFixture { /** Watchers and exact run-root identity are already armed at return from bind. */ readonly baseline: RemoteNativeSnapshot; snapshot(label: string): Promise; - injectOwnedRunLoss?(): Promise; + /** One-shot fault against the exact admitted Pi child; never a worker kill. */ + terminatePiProvider?(runtimeEnvironmentLeaseId: string): Promise; readFile(path: string): Promise; publishAction(path: string, text: string): Promise; + injectOwnedRunLoss(): 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; @@ -254,6 +326,9 @@ function readSnapshot(value: unknown, binding: RemoteNativeBinding, names: strin && /^\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"); + fail(row.incompleteReasons === undefined || (Array.isArray(row.incompleteReasons) && row.incompleteReasons.length <= INCOMPLETE_REASONS.length + && new Set(row.incompleteReasons).size === row.incompleteReasons.length + && row.incompleteReasons.every(reason => INCOMPLETE_REASONS.includes(reason as IncompleteReason))), "incomplete_reasons_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); @@ -429,7 +504,9 @@ export async function bindRemoteNativeFixture(options: RemoteNativeFixtureOption stopReceipt(); throw error; } + let piFaultAttempted = false; let closed = false, published = false, finished: RemoteNativeSnapshot | undefined; + let retiredTerminal: RemoteNativeSnapshot | undefined; const retainedFiles = new Map(); return { binding: binding!, remoteCwd: binding!.remoteCwd, actionFile, outsideTarget: options.crossRoot ? `${root}/cross-root-target` : null, baseline, @@ -438,6 +515,12 @@ export async function bindRemoteNativeFixture(options: RemoteNativeFixtureOption fail(!closed && !finished, "fixture_closed"); return readSnapshot(await rpc({ op: "snapshot" }), binding!, names, actionFile, options.runnerdSha256); }, + async terminatePiProvider(runtimeEnvironmentLeaseId) { + fail(!closed && !finished && published && !piFaultAttempted && /^[A-Za-z0-9._:-]{1,240}$/u.test(runtimeEnvironmentLeaseId), "pi_fault_admission"); + piFaultAttempted = true; // Uncertain delivery never retries the signal. + const receipt = await rpc({ op: "pi-provider-death", runtimeEnvironmentLeaseId }); + return validatePiProviderDeathReceipt(receipt, binding!, baseline.processes.root!, runtimeEnvironmentLeaseId); + }, 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!); } @@ -477,14 +560,14 @@ export async function bindRemoteNativeFixture(options: RemoteNativeFixtureOption if ("error" in receipt) throw receipt.error; fail(receipt.receivedAtMs <= receiptDeadlineAt, "receipt_deadline"); const result = readSnapshot(receipt.value, binding!, names, actionFile, options.runnerdSha256); + // The observer only seals its receipt after captured descendants retire. + // A lost filesystem watch still fails qualification, but must not require + // a second RPC to an already deleted lease just to close that observer. + const knownFilesystemGap = result.incompleteReasons !== undefined && result.incompleteReasons.length > 0 + && result.incompleteReasons.every(reason => FILESYSTEM_INCOMPLETE_REASONS.includes(reason)); + if (result.processes.captured && result.processes.live.length === 0 && (result.complete || knownFilesystemGap)) retiredTerminal = result; if (!(published && result.setup.published && result.complete && result.watcher.complete && result.processes.captured && result.processes.live.length === 0)) { - // Closed flags explain missing evidence without retaining SDK output, - // provider text, file contents or opaque observer control credentials. - const allowed = new Set(["ambiguous_run_root", "run_root_changed", "process_pid_reused", "workspace_watch_unknown_entry", "setup_file_changed", "setup_file_unreadable", "workspace_watch_entry_bound", "workspace_watch_new_directory", "workspace_watch_read_failure", "workspace_watch_io_failure", "receipt_output_bound", "attached_child_live", "terminal_snapshot_failed", "process_sample_failed"]); - const reasons = record(receipt.value).failureCodes; - const codes = Array.isArray(reasons) ? [...new Set(reasons.filter((code): code is string => typeof code === "string" && allowed.has(code)))].slice(0, allowed.size) : []; - const flags = [`published=${published && result.setup.published}`, `complete=${result.complete}`, `watcher=${result.watcher.complete}`, `captured=${result.processes.captured}`, `live=${result.processes.live.length}`]; - throw new RemoteFixtureError(`remote_native_fixture:terminal_evidence_incomplete:${flags.join(",")}:${codes.join(",")}`, { phase: "wait", code: "terminal_evidence_incomplete" }); + throw new IncompleteRemoteTerminalEvidenceError(result); } const files = record(record(receipt.value).files); for (const name of names) { @@ -503,7 +586,7 @@ export async function bindRemoteNativeFixture(options: RemoteNativeFixtureOption 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" }); } + if (!retiredTerminal) { stopReceipt(); await rpc({ op: "close" }); } }, }; } diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index e8b2f97dcc..980c5ebff2 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -1,3 +1,11 @@ +import type { RestartRunnerIdentity } from "./process-tree-owner.js"; +import { runNativeActiveStopFlow } from "./native-active-stop-flow.js"; +import { runPiControlsFlow } from "./pi-controls-flow.js"; +import { runCursorNativeFlow } from "./cursor-native-flow.js"; +import { createRemoteNativeBootstrap, createRemoteFixtureClient } from "./remote-native-bootstrap.js"; +import { runCopilotProtectionFlow } from "./copilot-protection-flow.js"; +import { runPiNativeFlow } from "./pi-native-flow.js"; +import { seedPiFile, collectPiFileEvidence } from "./pi-file-evidence.js"; import { runPlanTaskFlow } from "./plan-task-flow.js"; import { assertNativeCompletionSelection, NATIVE_COMPLETION_PREFLIGHT_ENV, verifyNativeCompletionPreflight } from "./native-completion-admission.js"; import { assertNativeInstructionSelection, verifyNativeInstructionPreflight, NATIVE_INSTRUCTION_PREFLIGHT_ENV, NATIVE_INSTRUCTION_SUITE, NATIVE_INSTRUCTION_DEFAULT_SHA256 } from "./native-instruction-consolidation.js"; @@ -7,10 +15,7 @@ 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 { runNativeProviderLossFlow } from "./native-provider-loss-flow.js"; -import { runCursorNativeFlow } from "./cursor-native-flow.js"; -import { createRemoteNativeBootstrap, createRemoteFixtureClient } from "./remote-native-bootstrap.js"; import { mayAllocateRemoteResources, runCleanupWithObservers, verifyCleanupAssertions, type CleanupAssertion } from "./cleanup-verification.js"; import { completionDelivery, type CompletionObservation } from "./completion-updates.js"; import { runInstructionPersistenceFlow } from "./instruction-persistence.js"; @@ -236,6 +241,7 @@ async function restartIsolatedPaperclipServer(input: { api: RunnerApi; requestId: string; deadlineAt: number; + preserveRunner?: RestartRunnerIdentity; }): Promise { const { controlDirectory, @@ -246,7 +252,7 @@ async function restartIsolatedPaperclipServer(input: { const temporaryRequestPath = `${requestPath}.${process.pid}.${input.requestId}.tmp`; await writeFile( temporaryRequestPath, - JSON.stringify({ requestId: input.requestId }), + JSON.stringify({ requestId: input.requestId, ...(input.preserveRunner ? { preserveRunner: input.preserveRunner } : {}) }), { encoding: "utf8", mode: 0o600 }, ); await rename(temporaryRequestPath, requestPath); @@ -584,7 +590,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" || ["plan_task_guidance", "blocker_guidance", "continuation_accounting", "continuation", "context_integrity", "agent_chat", "everyday_workflow", "first_task", "instruction_persistence", "cursor_native", "native_active_stop", "native_provider_loss", "public_mcp"].includes(execution.task.flow); + const companyRunFlow = execution.suite.id === "task-titles" || ["plan_task_guidance", "blocker_guidance", "continuation_accounting", "continuation", "context_integrity", "agent_chat", "everyday_workflow", "first_task", "instruction_persistence", "pi_native", "pi_controls", "copilot_protection", "cursor_native", "native_active_stop", "native_provider_loss", "public_mcp"].includes(execution.task.flow); const publicMcpUsage = execution.task.flow === "public_mcp" ? assistantUsage(execution.profile.provider === "claude" ? "anthropic" : "openai", execution.profile.model) : undefined; let publicMcpUserId = ""; const consoleDiagnostics: Array> = []; @@ -909,7 +915,7 @@ 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", "native_provider_loss"].includes(execution.task.flow) + && ["pi_native", "pi_controls", "copilot_protection", "cursor_native", "native_active_stop", "native_provider_loss"].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, @@ -1018,6 +1024,15 @@ 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 === "pi_controls") { + const story = await runPiControlsFlow({ + 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: `piControls.${check.id}`, expected: true }, passed: check.passed, detail: check.detail })); } else if (execution.task.flow === "native_provider_loss") { const story = await runNativeProviderLossFlow({ page, api, fixtures, execution, nonce, workspacePath, deadlineAt: startedAtMs + deadlineMs, @@ -1046,6 +1061,27 @@ for (const execution of executions) { }); 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 === "pi_native") { + const story = await runPiNativeFlow({ + page, api, fixtures, execution, nonce, workspacePath, deadlineAt: startedAtMs + deadlineMs, + restart: preserveRunner => restartIsolatedPaperclipServer({ api, requestId: `pi-native-${nonce}`, deadlineAt: startedAtMs + deadlineMs, preserveRunner }), + observe: (currentIssue, currentRuns) => { issue = currentIssue as IssueRecord; selectedRuns = currentRuns as RunRecord[]; }, + capture: captureScreenshot, + evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets), + remoteBootstrap, registerCleanupAssertion: remoteBootstrap ? registerBeforeEnvironmentTeardownAssertion : registerCleanupAssertion, + }); + issue = story.issue as IssueRecord; selectedRuns = story.runs as RunRecord[]; + matcherResults = story.checks.map(check => ({ matcher: { kind: "json_path" as const, path: `piNative.${check.id}`, expected: true }, passed: check.passed, detail: check.detail })); + } else if (execution.task.flow === "copilot_protection") { + const story = await runCopilotProtectionFlow({ + 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, registerBeforeEnvironmentTeardownAssertion, + }); + issue = story.issue as IssueRecord; selectedRuns = story.runs as RunRecord[]; + matcherResults = story.checks.map(check => ({ matcher: { kind: "json_path" as const, path: `copilotProtection.${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, diff --git a/tests/runner-e2e/selectors.ts b/tests/runner-e2e/selectors.ts index 1c48ae8d91..222b3eaf34 100644 --- a/tests/runner-e2e/selectors.ts +++ b/tests/runner-e2e/selectors.ts @@ -5,6 +5,7 @@ export interface RunnerSelectorOptions { all: boolean; list: boolean; matrixJson: boolean; + installedStartupOnly?: boolean; ids: string[]; suites: string[]; groups: string[]; @@ -53,6 +54,7 @@ export function parseRunnerSelectors( if (flag === "--all") options.all = true; else if (flag === "--list") options.list = true; else if (flag === "--matrix-json") options.matrixJson = true; + else if (flag === "--installed-startup-only") options.installedStartupOnly = true; else if (flag === "--headed") options.headed = true; else if (flag === "--ui") options.ui = true; else if (flag === "--debug") options.debug = true; diff --git a/tests/runner-e2e/server-config.test.ts b/tests/runner-e2e/server-config.test.ts index 121e0dc210..ff69beff4f 100644 --- a/tests/runner-e2e/server-config.test.ts +++ b/tests/runner-e2e/server-config.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, readFile, rm, symlink } from "node:fs/promises"; import { createServer } from "node:net"; import os from "node:os"; import path from "node:path"; @@ -8,7 +8,7 @@ import { reserveRunnerE2EDatabasePort, type LoopbackPortReservation, } from "./ports.js"; -import { prepareRunnerE2EServerConfig } from "./server-config.js"; +import { createRunnerE2ETemporaryRoot, prepareRunnerE2EServerConfig } from "./server-config.js"; const roots: string[] = []; const reservations: LoopbackPortReservation[] = []; @@ -22,6 +22,18 @@ afterEach(async () => { }); describe("isolated paid E2E database ports", () => { + it("uses a physical temporary root when the host temp directory has an alias", async () => { + const parent = await realpath(await mkdtemp(path.join(os.tmpdir(), "e2e-temp-alias-"))); + roots.push(parent); + const physical = path.join(parent, "physical"); + const alias = path.join(parent, "alias"); + await mkdir(physical); + await symlink(physical, alias); + const root = await createRunnerE2ETemporaryRoot(alias); + expect(root).toBe(await realpath(root)); + expect(path.dirname(root)).toBe(physical); + }); + it("keeps parallel database reservations distinct and held until server spawn", async () => { reservations.push( ...(await Promise.all( diff --git a/tests/runner-e2e/server-config.ts b/tests/runner-e2e/server-config.ts index c9e28b288a..3d9a42c055 100644 --- a/tests/runner-e2e/server-config.ts +++ b/tests/runner-e2e/server-config.ts @@ -1,8 +1,14 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { paperclipConfigSchema } from "../../packages/shared/src/config-schema.js"; import { reserveRunnerE2EDatabasePort } from "./ports.js"; +/** Runtime instructions and native filesystem grants must use the same path. */ +export async function createRunnerE2ETemporaryRoot(parent = os.tmpdir()) { + return await realpath(await mkdtemp(path.join(parent, "paperclip-runner-e2e-"))); +} + /** Seed only the disposable fixture. Onboarding preserves this validated config * and still creates the normal secrets and database. Restarts keep the same DB. */ diff --git a/tests/runner-e2e/server-restart-preservation-process.test.ts b/tests/runner-e2e/server-restart-preservation-process.test.ts new file mode 100644 index 0000000000..6315f69054 --- /dev/null +++ b/tests/runner-e2e/server-restart-preservation-process.test.ts @@ -0,0 +1,76 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { createRunnerE2EServerStopper } from "./server-stop.js"; +import { createProcessTreeOwner } from "./process-tree-owner.js"; +import { readProcessTable } from "./process-tree.js"; +import { readLinuxProcessStartedAt } from "../../packages/paperclip-runner/src/live/linux-process-start.js"; + +it.skipIf(process.platform === "win32")("keeps the real admitted daemon and child alive across controller exit, then drains both controller generations", async () => { + const directory = await mkdtemp(path.join(await realpath(os.tmpdir()), "pc-restart-owner-")); + const daemon = "/bin/sh"; + const fixtureDaemons = new Set(); + const entry = path.join(directory, "controller.cjs"); + const stop = createRunnerE2EServerStopper({ gracefulTimeoutMs: 3000, forcedTimeoutMs: 2000, + hasSpawnError: () => false, markExpectedStop: () => {}, log: () => {}, + // Only the fixture's declared role is synthetic. PID, start, ancestry, + // groups, observation and signal delivery all use the real kernel table. + createOwner: child => createProcessTreeOwner(child, { readTable: async () => { + const table = await readProcessTable(); + return table?.map(row => fixtureDaemons.has(row.pid) && row.kind === "sh" ? { ...row, kind: "paperclip-runnerd" } : row) ?? null; + } }), + }); + const closed: Promise[] = []; + try { + // This is a tiny inert process-role fixture, not a provider or real daemon. + await writeFile(entry, ` + const { spawn } = require('node:child_process'); + const fs = require('node:fs'); + const marker = ${JSON.stringify(path.join(directory, "child-ready"))} + process.pid; + const daemon = spawn(${JSON.stringify(daemon)}, ['-c', ${JSON.stringify("trap 'exit 0' TERM; /bin/sleep 600 & printf '%s' \"$!\" > \"$1\"; wait")}, 'fixture', marker], { detached: true, stdio: ['ignore', 'ignore', 'pipe'] }); + let daemonError = ''; daemon.stderr.on('data', chunk => { daemonError += chunk; }); + daemon.once('exit', (code, signal) => { if (process.connected) process.send({ failure: { code, signal, stderr: daemonError } }); }); + const background = spawn('/bin/sleep', ['600'], { detached: true, stdio: 'ignore' }); + process.on('SIGTERM', () => process.exit(0)); + const ready = setInterval(() => { + if (fs.existsSync(marker)) { clearInterval(ready); process.send({ daemon: daemon.pid, background: background.pid }); } + }, 10); + setInterval(() => {}, 1000); + `); + const start = async () => { + const child = spawn(process.execPath, [entry], { detached: true, stdio: ["ignore", "ignore", "pipe", "ipc"] }); + closed.push(new Promise(resolve => child.once("close", () => resolve()))); + const message = once(child, "message"); + await stop.watch(child); + const result = (await message)[0]; + expect(result.failure, JSON.stringify(result)).toBeUndefined(); + fixtureDaemons.add(result.daemon); + return { child, ids: result as { daemon: number; background: number } }; + }; + const first = await start(); + const before = await readProcessTable(); + expect(before).not.toBeNull(); + const runner = before!.find(row => row.pid === first.ids.daemon)!; + expect(runner.kind).toBe("sh"); + const descendants = before!.filter(row => row.parentPid === runner.pid); + expect(descendants.length).toBeGreaterThan(0); + await stop.forRestart(first.child, { processPid: runner.pid, processGroupId: runner.processGroupId, + processStartedAt: process.platform === "linux" ? readLinuxProcessStartedAt(runner.pid) : new Date(runner.started).toISOString() }); + const after = await readProcessTable(); + expect(after!.find(row => row.pid === runner.pid)?.started).toBe(runner.started); + for (const descendant of descendants) expect(after!.find(row => row.pid === descendant.pid)?.started).toBe(descendant.started); + expect(after!.some(row => row.pid === first.ids.background && !row.state?.startsWith("Z"))).toBe(false); + const second = await start(); + await stop.stopAll(); await Promise.all(closed); + const final = await readProcessTable(); + const expectedGone = [first.child.pid, first.ids.daemon, first.ids.background, ...descendants.map(row => row.pid), + second.child.pid, second.ids.daemon, second.ids.background]; + expect(final!.filter(row => expectedGone.includes(row.pid) && !row.state?.startsWith("Z"))).toEqual([]); + } finally { + try { await stop.stopAll(); await Promise.all(closed); } + finally { await rm(directory, { recursive: true, force: true }); } + } +}, 15000); diff --git a/tests/runner-e2e/server-restart-preservation.test.ts b/tests/runner-e2e/server-restart-preservation.test.ts new file mode 100644 index 0000000000..54d6031571 --- /dev/null +++ b/tests/runner-e2e/server-restart-preservation.test.ts @@ -0,0 +1,186 @@ +import type { ChildProcess } from "node:child_process"; +import { expect, it, vi } from "vitest"; +import { createProcessTreeOwner, parseRestartRunnerIdentity, restartProcessStartMatches } from "./process-tree-owner.js"; +import { createRunnerE2EServerStopper } from "./server-stop.js"; +import { diagnosticProcessKind, type ProcessObservation } from "./process-tree.js"; + +const started = "2026-10-02T13:55:27.000Z"; +const identity = { processPid: 200, processGroupId: 200, processStartedAt: started }; +const row = (pid: number, parentPid: number, group = pid, kind = "node"): ProcessObservation => + ({ pid, parentPid, processGroupId: group, started, kind, state: "S" }); +function fixture() { + let table = [row(process.pid, 1, 10), row(100, process.pid), row(200, 100, 200, "paperclip-runnerd"), + row(201, 200), row(300, 100), row(900, process.pid)]; + const signals: Array<[number, string]> = []; + const roots: Array = []; + const owners: Array> = []; + const root = (pid: number) => { + const child = { pid, exitCode: null as number | null, signalCode: null as NodeJS.Signals | null, + kill: (signal: string) => { + signals.push([pid, signal]); child.exitCode = 0; + table = table.filter(row => row.pid !== pid).map(row => row.parentPid === pid ? { ...row, parentPid: 1 } : row); + return true; + } }; + const handle = child as unknown as ChildProcess; + roots.push(handle); return handle; + }; + const stop = createRunnerE2EServerStopper({ gracefulTimeoutMs: 100, forcedTimeoutMs: 100, + hasSpawnError: () => false, markExpectedStop: () => {}, log: () => {}, + createOwner: child => { + const owner = createProcessTreeOwner(child, { readTable: async () => table.map(row => ({ ...row })), + signalGroup: (group, signal) => { signals.push([group, signal]); table = table.filter(row => row.processGroupId !== group); } }); + owners.push(owner); return owner; + } }); + return { stop, child: root(100), root, signals, owners, table: () => table, + replace: (next: ProcessObservation[]) => { table = next; }, close: () => owners.forEach(owner => owner.stopObserving()) }; +} + +it("matches Linux process birth receipts at ps precision without accepting another second", () => { + expect(restartProcessStartMatches(started, "2026-10-02T13:55:27.731Z", "linux")).toBe(true); + for (const expected of ["2026-10-02T13:55:26.999Z", "2026-10-02T13:55:28.000Z", "invalid"]) { + expect(restartProcessStartMatches(started, expected, "linux")).toBe(false); + } + expect(restartProcessStartMatches("invalid", started, "linux")).toBe(false); + expect(restartProcessStartMatches(started, "2026-10-02T13:55:27.731Z", "darwin")).toBe(false); + expect(restartProcessStartMatches(started, started, "darwin")).toBe(true); +}); + +it.skipIf(process.platform === "win32")("preserves only the admitted daemon tree across restart and finally retires old and new owners", async () => { + const f = fixture(); + try { + await f.stop.watch(f.child); + await f.stop.forRestart(f.child, identity); + expect(f.signals).toEqual([[100, "SIGTERM"], [300, "SIGTERM"]]); + expect(f.table().map(row => row.pid)).toContain(200); + // The daemon forks a new private group after controller loss; it stays owned. + f.replace([...f.table(), row(202, 201), row(400, process.pid), row(401, 400)]); + const replacement = f.root(400); await f.stop.watch(replacement); + await f.stop.stopAll(); + expect(f.table().map(row => row.pid).sort()).toEqual([process.pid, 900].sort()); + expect(f.signals.filter(([pid]) => pid === 200)).toEqual([[200, "SIGTERM"]]); + expect(f.signals.findIndex(([pid]) => pid === 400)).toBeLessThan(f.signals.findIndex(([pid]) => pid === 200)); + expect(f.signals.some(([pid]) => pid === 202)).toBe(true); + expect(f.signals.some(([pid]) => pid === 900)).toBe(false); + } finally { f.close(); } +}); + +it.skipIf(process.platform === "win32").each([ + ["foreign controller", (rows: ProcessObservation[]) => { rows.find(row => row.pid === 200)!.parentPid = 900; }], + ["reused PID", (rows: ProcessObservation[]) => { rows.find(row => row.pid === 200)!.started = "2026-10-02T13:55:28.000Z"; }], + ["wrong role", (rows: ProcessObservation[]) => { rows.find(row => row.pid === 200)!.kind = "node"; }], + ["mixed process group", (rows: ProcessObservation[]) => { rows.push(row(901, 900, 200)); }], + ["same controller group", (rows: ProcessObservation[]) => { rows.find(row => row.pid === 200)!.processGroupId = 100; }], + ["missing runner", (rows: ProcessObservation[]) => { rows.splice(rows.findIndex(row => row.pid === 200), 1); }], +] as const)("rejects %s without sending a restart signal", async (_name, mutate) => { + const f = fixture(); + try { + const rows = f.table(); mutate(rows); f.replace(rows); + await expect(f.stop.forRestart(f.child, identity)).rejects.toThrow(/runner|daemon|process group/); + expect(f.signals).toEqual([]); + } finally { f.close(); } +}); + +it.skipIf(process.platform === "win32")("fails if the admitted daemon dies instead of pretending recovery preserved it", async () => { + const f = fixture(); + try { + const kill = f.child.kill.bind(f.child); + f.child.kill = signal => { const result = kill(signal); f.replace(f.table().filter(row => row.pid !== 200)); return result; }; + await expect(f.stop.forRestart(f.child, identity)).rejects.toThrow("did not survive"); + await f.stop.stopAll(); + expect(f.table().map(row => row.pid).sort()).toEqual([process.pid, 900].sort()); + } finally { f.close(); } +}); + +it.skipIf(process.platform === "win32")("does not transfer ownership to a reused retained daemon group during final cleanup", async () => { + const f = fixture(); + try { + await f.stop.forRestart(f.child, identity); + f.replace(f.table().map(row => row.pid === 200 ? { ...row, started: "2026-10-02T14:00:00.000Z" } : row)); + await expect(f.stop.stopAll()).rejects.toThrow("cleanup incomplete"); + expect(f.signals.some(([pid]) => pid === 200)).toBe(false); + } finally { f.close(); } +}); + +it.each([null, {}, { ...identity, processPid: 0 }, { ...identity, processGroupId: 100 }, { ...identity, processStartedAt: "bad" }])( + "rejects malformed restart identity %j", value => { expect(() => parseRestartRunnerIdentity(value)).toThrow(); }); + +it.skipIf(process.platform === "win32")("keeps the first final-cleanup deadline after an inspection failure and repeated stop", async () => { + const f = fixture(); + const clock = vi.spyOn(Date, "now"); + try { + await f.stop.forRestart(f.child, identity); + clock.mockReturnValue(10_000); + const owner = f.owners[0]!; + const observe = owner.observe; + let fail = true; + owner.observe = async () => { if (fail) { fail = false; throw new Error("one inspection failure"); } await observe(); }; + await expect(f.stop(f.child)).rejects.toThrow("inspection failure"); + clock.mockReturnValue(20_000); + await f.stop(f.child); + expect(f.signals.filter(([pid]) => pid === 200)).toEqual([[200, "SIGKILL"]]); + } finally { clock.mockRestore(); f.close(); } +}); + +it.skipIf(process.platform === "win32")("final cancellation interrupts restart and retires its preserved descendants", async () => { + const f = fixture(); + let cleanup: Promise | undefined; + try { + const kill = f.child.kill.bind(f.child); + f.child.kill = signal => { const result = kill(signal); cleanup = f.stop.stopAll(); return result; }; + await expect(f.stop.forRestart(f.child, identity)).rejects.toThrow("Final shutdown interrupted"); + await cleanup; + expect(f.table().map(row => row.pid).sort()).toEqual([process.pid, 900].sort()); + } finally { f.close(); } +}); + +it.skipIf(process.platform === "win32")("final cleanup still retires the old runner when replacement startup failed", async () => { + const f = fixture(); + try { + await f.stop.forRestart(f.child, identity); + const replacement = f.root(400); + Object.assign(replacement, { exitCode: 1 }); + await f.stop.watch(replacement); + await f.stop.stopAll(); + expect(f.table().map(row => row.pid).sort()).toEqual([process.pid, 900].sort()); + expect(f.signals.filter(([pid]) => pid === 200)).toEqual([[200, "SIGTERM"]]); + } finally { f.close(); } +}); + + +it("normalizes only the exact Linux comm truncation, never a nearby role name", () => { + expect(diagnosticProcessKind("/tmp/paperclip-runne", "linux")).toBe("paperclip-runnerd"); + expect(diagnosticProcessKind("/tmp/paperclip-runnerd", "darwin")).toBe("paperclip-runnerd"); + expect(diagnosticProcessKind("paperclip-runne", "darwin")).toBe("other"); + for (const name of ["paperclip-runn", "paperclip-runner", "paperclip-runneX", "paperclip-runnerd-other"]) { + expect(diagnosticProcessKind(name, "linux")).toBe("other"); + } +}); + + +it.skipIf(process.platform === "win32")("still drains the retained daemon after replacement cleanup reports failure", async () => { + const f = fixture(); + try { + await f.stop.forRestart(f.child, identity); + f.replace([...f.table(), row(400, process.pid)]); + const replacement = f.root(400); await f.stop.watch(replacement); + const latestOwner = f.owners[1]!; + latestOwner.observe = async () => { throw new Error("replacement inspection failed"); }; + await expect(f.stop.stopAll()).rejects.toThrow("cleanup incomplete"); + expect(f.signals.filter(([pid]) => pid === 200)).toEqual([[200, "SIGTERM"]]); + expect(f.signals.findIndex(([pid]) => pid === 400)).toBeLessThan(f.signals.findIndex(([pid]) => pid === 200)); + } finally { f.close(); } +}); + + +it.skipIf(process.platform === "win32")("does not reuse an already-retired ordinary restart controller's old cleanup deadline", async () => { + const f = fixture(); + const clock = vi.spyOn(Date, "now").mockReturnValue(10_000); + try { + await f.stop(f.child); + clock.mockReturnValue(20_000); + f.replace([...f.table(), row(400, process.pid), row(401, 400)]); + const replacement = f.root(400); await f.stop.watch(replacement); + await f.stop.stopAll(); + expect(f.signals.filter(([pid]) => pid === 401)).toEqual([[401, "SIGTERM"]]); + } finally { clock.mockRestore(); f.close(); } +}); diff --git a/tests/runner-e2e/server-stop.test.ts b/tests/runner-e2e/server-stop.test.ts index a4baf54c5d..8cf57bcdc8 100644 --- a/tests/runner-e2e/server-stop.test.ts +++ b/tests/runner-e2e/server-stop.test.ts @@ -46,6 +46,10 @@ function stopper(gracefulTimeoutMs = 2_000) { }); return { stop, logs, errors }; } +function expectSuccessfulWrapperReceipt(wrapper: ChildProcess, messages: unknown[], diagnostics = "") { + expect([wrapper.exitCode, wrapper.signalCode], diagnostics).toEqual([0, null]); + expect(messages, diagnostics).toContainEqual({ exitCode: 0, signalCode: null }); +} afterEach(async () => { for (const child of children.splice(0)) { if (child.exitCode !== null || child.signalCode !== null) continue; @@ -153,15 +157,19 @@ it.skipIf(process.platform === "win32")("lets launcher cancellation join wrapper // 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"); + // Process exit is not IPC completion. Register both barriers before + // cancellation so the final worker receipt is drained before asserting it. + const closed = once(wrapper, "close"); + const disconnected = once(wrapper, "disconnect"); 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 Promise.all([closed, disconnected]); await cancellation; - expect(messages, wrapperErrors).toContainEqual({ message: "close-complete", pid: workerPid }); - expect(messages).toContainEqual({ exitCode: 0, signalCode: null }); + const diagnostics = `${wrapperErrors}\nwrapper exit=${wrapper.exitCode} signal=${wrapper.signalCode} connected=${wrapper.connected}`; + expect(messages, diagnostics).toContainEqual({ message: "close-complete", pid: workerPid }); + expectSuccessfulWrapperReceipt(wrapper, messages, diagnostics); expect(() => process.kill(workerPid!, 0)).toThrow(); } finally { owner.stopObserving(); @@ -169,6 +177,21 @@ it.skipIf(process.platform === "win32")("lets launcher cancellation join wrapper } }); +it.each(["failed", "missing"] as const)("rejects a %s worker receipt even when the wrapper closes successfully", async receipt => { + const { entry } = await fixture(receipt === "failed" + ? "process.send({ exitCode: 23, signalCode: null }, () => process.exit(0));" + : "process.exit(0);"); + const wrapper = start(entry); + const messages: unknown[] = []; + wrapper.on("message", message => messages.push(message)); + const closed = once(wrapper, "close"); + const disconnected = once(wrapper, "disconnect"); + await Promise.all([closed, disconnected]); + expect([wrapper.exitCode, wrapper.signalCode]).toEqual([0, null]); + expect(messages).toEqual(receipt === "failed" ? [{ exitCode: 23, signalCode: null }] : []); + expect(() => expectSuccessfulWrapperReceipt(wrapper, messages)).toThrow(); +}); + it.skipIf(process.platform === "win32")("escalates the owned server group so a hung descendant cannot remain", async () => { const { entry } = await fixture(` @@ -255,8 +278,8 @@ it.skipIf(process.platform === "win32")("recovers a failed cleanup without repea }); try { const failed = expect(stop(child)).rejects.toThrow("transient inspection failure"); - // Process-table inspection can take longer than 100ms on a loaded host. - // Keep the child alive until the intended inspection failure actually occurs. + // Hold the child until the one-shot failure is actually injected. A fixed + // delay can let it exit during the preceding successful inspection. await inspectionFailed; if (child.connected) child.send("finish"); await failed; diff --git a/tests/runner-e2e/server-stop.ts b/tests/runner-e2e/server-stop.ts index bbeddc734c..bcd6ff59c7 100644 --- a/tests/runner-e2e/server-stop.ts +++ b/tests/runner-e2e/server-stop.ts @@ -1,5 +1,5 @@ import type { ChildProcess } from "node:child_process"; -import { createProcessTreeOwner, incompleteTreeFallback } from "./process-tree-owner.js"; +import { createProcessTreeOwner, incompleteTreeFallback, type RestartRunnerIdentity } from "./process-tree-owner.js"; // The wrapper receives Playwright's group signal; only it signals Paperclip. export const runnerE2EServerDetached = process.platform !== "win32"; @@ -16,6 +16,10 @@ export function createRunnerE2EServerStopper(options: { type State = { owner: ReturnType; promise?: Promise; + restartPromise?: Promise; + finalRequested?: boolean; + finalPhaseStarted?: boolean; + finalComplete?: boolean; gracefulDeadline?: number; forcedDeadline?: number; gracefulSent: boolean; @@ -23,16 +27,21 @@ export function createRunnerE2EServerStopper(options: { escalationLogged: boolean; }; const states = new WeakMap(); + const watched = new Set(); + let finalGraceDeadline: number | undefined; + let finalForcedDeadline: number | undefined; + let finalAllPromise: Promise | undefined; 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); + watched.add(child); } return state; } - async function stopOnce(child: ChildProcess, state: State, signal: NodeJS.Signals) { + async function stopOnce(child: ChildProcess, state: State, signal: NodeJS.Signals, restarting = false) { state.gracefulDeadline ??= Date.now() + options.gracefulTimeoutMs; await state.owner.observe(); if (!exited(child) && !state.gracefulSent) { @@ -40,22 +49,28 @@ export function createRunnerE2EServerStopper(options: { } while (Date.now() < state.gracefulDeadline) { await state.owner.observe(); + if (restarting && state.finalRequested) throw new Error("Final shutdown interrupted controller restart"); if (exited(child)) { + if (restarting) { + state.owner.assertRestartRunnerAlive(); + if (!state.owner.restartCleanupGroups().size) return; + } 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))); + const unsignaled = new Set((restarting ? [...state.owner.restartCleanupGroups()] : 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 (restarting) throw new Error("Controller restart did not retire non-durable children within its graceful deadline"); 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; + state.forcedDeadline ??= finalForcedDeadline ?? Date.now() + options.forcedTimeoutMs; do { await state.owner.observe(); if (exited(child) && !state.owner.liveGroups().length) { state.owner.stopObserving(); return; } @@ -66,7 +81,19 @@ export function createRunnerE2EServerStopper(options: { 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 => { + state.finalRequested = true; + state.promise ??= Promise.resolve().then(async () => { + if (state.restartPromise && !state.finalPhaseStarted) { + await state.restartPromise.catch(() => {}); + state.finalPhaseStarted = true; + // A completed restart is a separate phase, not elapsed cleanup time. + state.gracefulDeadline = finalGraceDeadline ?? Date.now() + options.gracefulTimeoutMs; + state.forcedDeadline = undefined; + state.groupSignals.clear(); + } + await stopOnce(child, state, signal); + state.finalComplete = true; + }).catch(async error => { try { await incompleteTreeFallback(error, child, state, state.gracefulDeadline!, options.forcedTimeoutMs); } finally { @@ -77,5 +104,39 @@ export function createRunnerE2EServerStopper(options: { repeating the graceful signal. */ return state.promise; }; - return Object.assign(stop, { watch: (child: ChildProcess) => watch(child).owner.observe() }); + return Object.assign(stop, { + watch: (child: ChildProcess) => watch(child).owner.observe(), + forRestart: (child: ChildProcess, identity: RestartRunnerIdentity): Promise => { + options.markExpectedStop(child); + const state = watch(child); + if (state.finalRequested || state.restartPromise) return Promise.reject(new Error("Restart already requested or final cleanup active")); + state.restartPromise = (async () => { + await state.owner.preserveRunnerForRestart(identity); + options.log(`Controller restart preserves admitted runner pid=${identity.processPid} pgid=${identity.processGroupId} started=${identity.processStartedAt}\n`); + await stopOnce(child, state, "SIGTERM", true); + })(); + return state.restartPromise; + }, + stopAll: (signal: NodeJS.Signals = "SIGTERM"): Promise => { + // Replacement controller drain precedes old daemon retirement. All + // generations share the original final-cleanup window, including retries. + finalGraceDeadline ??= Math.min(Date.now() + options.gracefulTimeoutMs, + ...[...watched].map(child => states.get(child)!).filter(state => state.finalRequested && !state.finalComplete + && (!state.restartPromise || state.finalPhaseStarted)).map(state => state.gracefulDeadline ?? Infinity)); + finalForcedDeadline ??= finalGraceDeadline + options.forcedTimeoutMs; + for (const child of watched) { + const state = states.get(child)!; + state.finalRequested = true; + state.gracefulDeadline ??= finalGraceDeadline; + } + finalAllPromise ??= Promise.resolve().then(async () => { + const failures: unknown[] = []; + for (const child of [...watched].reverse()) { + try { await stop(child, signal); } catch (error) { failures.push(error); } + } + if (failures.length) throw new AggregateError(failures, "Owned server cleanup incomplete"); + }).catch(error => { finalAllPromise = undefined; throw error; }); + return finalAllPromise; + }, + }); } diff --git a/tests/runner-e2e/server.ts b/tests/runner-e2e/server.ts index 57b3f37d14..1a5ecf34b4 100644 --- a/tests/runner-e2e/server.ts +++ b/tests/runner-e2e/server.ts @@ -1,3 +1,6 @@ +import { parseRestartRunnerIdentity, type RestartRunnerIdentity } from "./process-tree-owner.js"; +import { usesInstalledCli, verifyInstalledCli } from "./installed-cli.js"; +import { runnerMatrix } from "./catalog.js"; import { createRunnerE2EServerStopper, runnerE2EServerDetached } from "./server-stop.js"; import { runnerE2ETypeScriptProcessArgs } from "./web-server-command.js"; import { qualifyLegacyClaudeCli } from "./legacy-claude-cli.js"; @@ -26,6 +29,12 @@ const configPath = required("PAPERCLIP_CONFIG"); const port = required("PAPERCLIP_RUNNER_E2E_PORT"); const repositoryRoot = path.resolve(import.meta.dirname, "../.."); const paperclipCli = path.join(repositoryRoot, "tests/runner-e2e/server-entry.ts"); +const executionIds: string[] = JSON.parse(process.env.PAPERCLIP_RUNNER_E2E_EXECUTION_IDS ?? "[]"); +const selectedExecutions = usesInstalledCli(process.env) ? executionIds.map(id => { + const execution = runnerMatrix.find(row => row.id === id); + if (!execution) throw new Error("Unknown installed CLI proof execution"); + return execution; +}) : []; const { controlDirectory, restartRequestPath, @@ -128,9 +137,11 @@ async function startServer() { if (shutdownRequested()) { throw new Error("Refusing to start Paperclip after wrapper shutdown"); } + // Recheck bytes and dependency resolution on every controller restart. + const installed = await verifyInstalledCli(process.env, selectedExecutions); const candidate = spawn( process.execPath, - installedRelease?.args ?? runnerE2ETypeScriptProcessArgs(repositoryRoot, paperclipCli, ["onboard", "--yes", "--run"]), + installed ? [installed.entry, "onboard", "--yes", "--run"] : runnerE2ETypeScriptProcessArgs(repositoryRoot, paperclipCli, ["onboard", "--yes", "--run"]), { cwd: installedRelease?.cwd ?? repositoryRoot, env: definedServerEnvironment, @@ -237,6 +248,7 @@ async function waitForHealthToStop() { interface RestartRequest { requestId: string; + preserveRunner?: RestartRunnerIdentity; } async function readRestartRequest(): Promise { @@ -262,7 +274,8 @@ async function readRestartRequest(): Promise { ) { return null; } - return { requestId }; + const preserveRunner = (value as { preserveRunner?: unknown }).preserveRunner; + return { requestId, ...(preserveRunner === undefined ? {} : { preserveRunner: parseRestartRunnerIdentity(preserveRunner) }) }; } async function writeRestartAck( @@ -284,12 +297,13 @@ async function writeRestartAck( await rename(temporaryAckPath, restartAckPath); } -async function restartServer(requestId: string) { +async function restartServer({ requestId, preserveRunner }: RestartRequest) { activeRestartRequestId = requestId; appendLog(`\nRestart request ${requestId}: stopping Paperclip\n`); const previous = child; if (!previous) throw new Error("No Paperclip server is available to restart"); - await stopServer(previous); + if (preserveRunner) await stopServer.forRestart(previous, preserveRunner); + else await stopServer(previous); if (child === previous) child = null; // Do not mistake an orphaned old server for a healthy replacement. The port // must stop answering before the next launcher is allowed to start. @@ -339,13 +353,12 @@ async function supervise() { const request = await readRestartRequest(); if (request && request.requestId !== lastRestartRequestId) { lastRestartRequestId = request.requestId; - await restartServer(request.requestId); + await restartServer(request); } await delay(200); } - const running = child; - if (running) await stopServer(running, shutdownSignal ?? "SIGTERM"); + await stopServer.stopAll(shutdownSignal ?? "SIGTERM"); } let exitCode = 0; @@ -364,15 +377,10 @@ try { ); } } - const running = child; - if (running) { - try { - await stopServer(running); - } catch (stopError) { - appendLog( - `Failed to stop Paperclip after supervisor failure: ${stopError instanceof Error ? stopError.message : String(stopError)}\n`, - ); - } + try { + await stopServer.stopAll(); + } catch (stopError) { + appendLog(`Failed to stop Paperclip after supervisor failure: ${stopError instanceof Error ? stopError.message : String(stopError)}\n`); } } diff --git a/tests/runner-e2e/types.ts b/tests/runner-e2e/types.ts index 4a88ef5ca2..9bfb983f06 100644 --- a/tests/runner-e2e/types.ts +++ b/tests/runner-e2e/types.ts @@ -34,7 +34,10 @@ export type RunnerTaskFlow = | "plan_approval_completion" | "warm_three_turn" | "instruction_persistence" + | "pi_native" + | "pi_controls" | "native_active_stop" + | "copilot_protection" | "native_provider_loss" | "cursor_native"; diff --git a/tests/runner-e2e/user-actions.ts b/tests/runner-e2e/user-actions.ts index ebe43eadb3..42965012c3 100644 --- a/tests/runner-e2e/user-actions.ts +++ b/tests/runner-e2e/user-actions.ts @@ -1,4 +1,22 @@ -import { expect, type Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; + +/** Rich editors interpret pasted Markdown; fill inserts literal paragraph text. */ +export async function fillTaskPrompt(editor: Locator, prompt: string): Promise { + if (!/^ {0,3}(?:`{3,}|~{3,})/m.test(prompt) + || await editor.evaluate(element => element.tagName === "TEXTAREA")) { + await editor.fill(prompt); + return; + } + await editor.fill(""); + await editor.focus(); + await editor.evaluate((element, text) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }); + element.dispatchEvent(event); + if (!event.defaultPrevented) throw new Error("Task editor did not accept the Markdown paste"); + }, prompt); +} export async function createTaskThroughUi(input: { page: Page; @@ -46,9 +64,7 @@ export async function createTaskThroughUi(input: { const titleInput = dialog.getByRole("textbox", { name: "Task title", exact: true }); if (await titleInput.isVisible()) await titleInput.fill(input.title); else if (input.requireExplicitTitle) throw new Error("This title-preservation case requires a visible explicit title input"); - await dialog - .getByRole("textbox", { name: "editable markdown", exact: true }) - .fill(input.prompt); + await fillTaskPrompt(dialog.getByRole("textbox", { name: "editable markdown", exact: true }), input.prompt); if (input.workMode !== "standard") { await dialog.getByRole("button", { name: "Add to composer", exact: true }).click(); await input.page.getByTestId(input.workMode === "planning" ? "composer-add-plan" : "composer-add-ask").click(); diff --git a/tests/runner-e2e/web-server-command.ts b/tests/runner-e2e/web-server-command.ts index de88292fcb..7735119d24 100644 --- a/tests/runner-e2e/web-server-command.ts +++ b/tests/runner-e2e/web-server-command.ts @@ -21,3 +21,12 @@ export function runnerE2EWebServerCommand(repositoryRoot: string) { export function runnerE2ETypeScriptProcessArgs(repositoryRoot: string, entry: string, args: string[] = []) { return ["--import", path.join(repositoryRoot, "cli/node_modules/tsx/dist/loader.mjs"), entry, ...args]; } + +// pnpm's generated playwright bin shim adds NODE_PATH, even when the caller +// rejected ambient injection. The installed lane invokes the public JS bin +// with the current Node directly, keeping the WebServer environment closed. +export function runnerE2EPlaywrightInvocation(repositoryRoot: string, args: string[], installed: boolean) { + return installed + ? { command: process.execPath, args: [path.join(repositoryRoot, "node_modules/@playwright/test/cli.js"), ...args] } + : { command: "pnpm", args: ["exec", "playwright", ...args] }; +} diff --git a/tests/runner-e2e/workflow-security.test.ts b/tests/runner-e2e/workflow-security.test.ts index c1173c16be..9588de3bf6 100644 --- a/tests/runner-e2e/workflow-security.test.ts +++ b/tests/runner-e2e/workflow-security.test.ts @@ -15,12 +15,12 @@ const everydayOracleImage = "python@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285"; describe("public repository paid workflow security", () => { - it("keeps the manual EC2 image build credential-free and pins the authorized target", async () => { + it("keeps the manual hosted Linux image build credential-free and pins the authorized target", async () => { const workflow = await readFile(path.join(repositoryRoot, ".github/workflows/docker-runner-check.yml"), "utf8"); const manual = workflow.slice(workflow.indexOf(" authorize_manual:")); expect(manual.match(/AWS_CI_TRUSTED_USER_IDS/gu)).toHaveLength(2); expect(manual.match(/test "\$REPOSITORY_ID" = 1170821064/gu)).toHaveLength(2); - expect(manual).toContain('runs-on: runs-on/fleet=paperclip-public-pr-x64/env=public-ci'); + expect(manual.slice(manual.indexOf(' manual_image:'))).toContain('runs-on: ubuntu-latest'); expect(manual).toContain('repos/$REPOSITORY/git/ref/heads/$TARGET_BRANCH'); expect(manual).toContain('ref: ${{ needs.authorize_manual.outputs.target_sha }}'); expect(manual).toContain('SOURCE_SHA: ${{ needs.authorize_manual.outputs.target_sha }}'); diff --git a/ui/src/adapters/codex-local/config-fields.test.tsx b/ui/src/adapters/codex-local/config-fields.test.tsx index 6bea64810f..1ae24c0701 100644 --- a/ui/src/adapters/codex-local/config-fields.test.tsx +++ b/ui/src/adapters/codex-local/config-fields.test.tsx @@ -12,7 +12,7 @@ import { CodexLocalConfigFields } from "./config-fields"; beforeAll(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); }); -async function renderMarkup(node: ReactNode, expand?: string): Promise { +async function renderMarkup(node: ReactNode, expand?: string, inspect?: (container: HTMLElement) => void): Promise { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -20,13 +20,14 @@ async function renderMarkup(node: ReactNode, expand?: string): Promise { if (expand) await act(async () => { container.querySelector(`[aria-label="${expand}"]`)?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); }); - const html = document.body.innerHTML; + inspect?.(container); + const html = container.innerHTML + Array.from(document.body.children).filter(child => child !== container).map(child => child.outerHTML).join(""); await act(async () => root.unmount()); container.remove(); return html; } -async function renderRunner(config: Record, expand?: string, openAiDotEnabled = false): Promise { +async function renderRunner(config: Record, expand?: string, inspect?: (container: HTMLElement) => void): Promise { return renderMarkup( , expand?: string, op openAiDotEnabled={openAiDotEnabled} /> , - expand, + expand, inspect, ); } @@ -96,7 +97,7 @@ describe("Paperclip Runner Codex configuration", () => { expect(html).not.toContain("Ask for untrusted operations"); }); - it("offers qualified Claude and keeps candidate ACP agents visibly disabled", async () => { + it("offers qualified Claude, Cursor and Pi while keeping Copilot disabled", async () => { const html = await renderRunner({ provider: "acpx", acpxAgent: "claude", @@ -105,18 +106,40 @@ describe("Paperclip Runner Codex configuration", () => { expect(html).toContain('ACP agents'); expect(html).toContain("ACP agent"); - expect(html).toContain('aria-label="ACP agent"'); - expect(html.match(/role="option"[^>]*data-disabled=""/g)).toHaveLength(2); - expect(html).toContain('Cursor'); - expect(html).not.toContain('Cursor — qualification pending'); - expect(html).toContain('GitHub Copilot — qualification pending'); - expect(html).toContain('Pi — qualification pending'); + const options = new DOMParser().parseFromString(html, "text/html").querySelectorAll('[role="option"]'); + expect(Array.from(options, option => ({ label: option.textContent?.trim(), disabled: option.hasAttribute("data-disabled") }))) + .toEqual(expect.arrayContaining([ + { label: "Claude", disabled: false }, { label: "Cursor", disabled: false }, + { label: "Pi", disabled: false }, { label: "GitHub Copilot — qualification pending", disabled: true }, + ])); expect(html).not.toContain("Codex via ACPX"); expect(html).not.toContain("ACPX Codex"); expect(html).not.toContain("Pi via ACPX"); expect(html).toContain("Allow Paperclip reads"); }); + it.each([undefined, "agent", "plan", "ask"])("displays saved Cursor mode %s", async acpxSessionMode => { + const selected = acpxSessionMode ?? "agent"; + await renderRunner({ provider: "acpx", acpxAgent: "cursor", acpxSessionMode }, undefined, container => { + const modes = container.querySelectorAll('[aria-label="Cursor mode"]'); + expect(modes).toHaveLength(1); + expect((modes[0] as HTMLSelectElement).value).toBe(selected); + }); + }); + + it.each([undefined, "off", "low", "high", "max"])("displays saved Pi thinking level %s", async piThinkingLevel => { + await renderRunner({ provider: "acpx", acpxAgent: "pi", piThinkingLevel }, undefined, container => { + const mode = container.querySelector('[aria-label="Pi thinking level"]'); + expect((mode as HTMLSelectElement).value).toBe(piThinkingLevel ?? "low"); + }); + }); + it("shows unsupported saved Pi level without aliasing it", async () => { + expect(await renderRunner({ provider: "acpx", acpxAgent: "pi", piThinkingLevel: "medium" })).toContain("Unsupported saved thinking level"); + }); + it.each(["claude", "copilot", "pi"])("does not expose Cursor mode for %s", async acpxAgent => { + expect(await renderRunner({ provider: "acpx", acpxAgent })).not.toContain('aria-label="Cursor mode"'); + }); + it("falls back to the fail-closed Codex permission mode", async () => { const html = await renderRunner({ codexPermissionMode: "unrestricted" }); diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index e150886c2e..6147d5f89c 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -36,7 +36,6 @@ const instructionsFileHint = "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime. Note: Codex may still auto-apply repo-scoped AGENTS.md files from the workspace."; const defaultOpenCodeRunnerModel = "openrouter/deepseek/deepseek-v4-flash-0731"; const defaultAcpxClaudeModel = "claude-sonnet-5"; -const defaultAcpxPiModel = "openrouter/deepseek/deepseek-v4-flash-0731"; const defaultClaudeManagedModel = "claude-sonnet-5"; const defaultAwsAgentCoreModel = "global.anthropic.claude-sonnet-4-6"; const runnerHarnessOptions = [ @@ -233,12 +232,14 @@ export function CodexLocalConfigFields({ ...values!.adapterSchemaValues, provider, acpxSessionMode: undefined, + piThinkingLevel: undefined, ...(provider === "acpx" ? { acpxAgent: grok ? "grok" : "claude" } : {}), }, }); } else { mark("adapterConfig", "provider", provider); mark("adapterConfig", "acpxSessionMode", undefined); + mark("adapterConfig", "piThinkingLevel", undefined); mark("adapterConfig", "model", model); if (provider === "openai_dot") { mark("adapterConfig", "lifecycleMode", "per_turn"); @@ -274,16 +275,17 @@ export function CodexLocalConfigFields({ checked={runnerSchemaValue("allowUnmeteredProvider", false) === true} onChange={value => updateRunnerSchemaValue("allowUnmeteredProvider", value)} /> } {runnerManaged && runnerProvider === "acpx" && runnerSchemaValue("acpxAgent", "claude") !== "grok" && ( - + )} - {runnerManaged && runnerProvider === "acpx" && runnerSchemaValue("acpxAgent", "claude") === "cursor" && ( - - updateRunnerSchemaValue("piThinkingLevel", event.target.value)}> + {!["off", "low", "high", "max"].includes(String(runnerSchemaValue("piThinkingLevel", "low"))) && } + )} diff --git a/ui/src/adapters/config-sections.test.tsx b/ui/src/adapters/config-sections.test.tsx index 8073eb5935..7a37b0f1a9 100644 --- a/ui/src/adapters/config-sections.test.tsx +++ b/ui/src/adapters/config-sections.test.tsx @@ -24,7 +24,7 @@ async function renderMarkup(node: ReactNode, expand?: string): Promise { if (expand) await act(async () => { container.querySelector(`[aria-label="${expand}"]`)?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); }); - const html = container.innerHTML; + const html = container.innerHTML + Array.from(document.body.children).filter(child => child !== container).map(child => child.outerHTML).join(""); await act(async () => root.unmount()); container.remove(); return html; @@ -35,6 +35,7 @@ async function renderSection( adapterType: string, section: AdapterConfigSection, config: Record = {}, + expand?: string, ) { return renderMarkup( @@ -52,6 +53,7 @@ async function renderSection( hideInstructionsFile /> , + expand, ); } @@ -91,10 +93,15 @@ describe("adapter configuration sections", () => { it("keeps ACP agent admission choices in the adapter section", async () => { const config = { provider: "acpx", acpxAgent: "claude" }; - const adapter = await renderSection(CodexLocalConfigFields, "paperclip_runner", "adapter", config); + const adapter = await renderSection(CodexLocalConfigFields, "paperclip_runner", "adapter", config, "ACP agent"); const policy = await renderSection(CodexLocalConfigFields, "paperclip_runner", "runPolicy", config); - expect(adapter).toContain('aria-label="ACP agent"'); + const options = new DOMParser().parseFromString(adapter, "text/html").querySelectorAll('[role="option"]'); + expect(Array.from(options, option => ({ label: option.textContent?.trim(), disabled: option.hasAttribute("data-disabled") }))) + .toEqual(expect.arrayContaining([ + { label: "Claude", disabled: false }, { label: "Pi", disabled: false }, + { label: "Cursor", disabled: false }, { label: "GitHub Copilot — qualification pending", disabled: true }, + ])); expect(adapter).not.toContain("Runner lifecycle"); expect(policy).toContain("Runner lifecycle"); expect(policy).not.toContain("ACP agent"); diff --git a/ui/src/adapters/paperclip-runner/index.ts b/ui/src/adapters/paperclip-runner/index.ts index 18fbef359a..d7ebfbb2cb 100644 --- a/ui/src/adapters/paperclip-runner/index.ts +++ b/ui/src/adapters/paperclip-runner/index.ts @@ -690,6 +690,9 @@ function parsePrpEvent( const eventType = text(event.eventType); const payload = record(event.payload); if (isRunLogOnlyProviderEvent(eventType, payload)) return []; + const failure = piRuntimeFailureKey(event, payload); + if (failure !== null && failure === state.previousPiRuntimeFailure) return []; + state.previousPiRuntimeFailure = failure; const family = eventType.startsWith("plan.") ? "plan" : eventType.startsWith("tool.execution.") ? "tool_execution" : eventType.startsWith("research.") ? "research" diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 2ebb12e938..ac179b36c0 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -912,6 +912,35 @@ describe("MarkdownEditor", () => { }); }); + it("handles Markdown paste once before the inner editor receives it", async () => { + const root = createRoot(container); + await act(async () => { + root.render( {}} />); + }); + await flush(); + const editable = container.querySelector('[data-testid="mdx-editor"]')!; + const innerPaste = vi.fn(); + editable.addEventListener("paste", innerPaste); + const pasted = '```json\n{"content":"nonce\\n"}\n```'; + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { types: ["text/plain"], getData: () => pasted }, + }); + await act(async () => { editable.dispatchEvent(event); }); + expect(event.defaultPrevented).toBe(true); + expect(mdxEditorMockState.insertedMarkdownValues).toEqual([pasted]); + expect(innerPaste).not.toHaveBeenCalled(); + + const plain = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(plain, "clipboardData", { + value: { types: ["text/plain"], getData: () => "ordinary text" }, + }); + await act(async () => { editable.dispatchEvent(plain); }); + expect(plain.defaultPrevented).toBe(false); + expect(innerPaste).toHaveBeenCalledOnce(); + await act(async () => { root.unmount(); }); + }); + it("escapes angle brackets in pasted markdown", async () => { const root = createRoot(container); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index b69a6fbafb..b4b0c5fc78 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -1284,6 +1284,9 @@ export const MarkdownEditor = forwardRef if (!looksLikeMarkdownPaste(rawText)) return; event.preventDefault(); + // Lexical also handles paste on the editable element. Once Markdown is + // inserted here, prevent that handler from inserting the plain text again. + event.stopPropagation(); ref.current.insertMarkdown(escapeUnsupportedAngleBrackets(normalizeMarkdown(rawText))); }, []); diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index fe808fe9ed..6cbf89c6d9 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -2186,6 +2186,35 @@ describe("TaskChatThread runtime transcript selection", () => { ); }); + it("keeps a pending native permission answerable after same-turn steering", async () => { + const runId = "native-pending-steered"; + nativeTranscriptState.transcriptByRun.set(runId, [ + { kind: "runtime_request", ts: "2026-08-25T18:00:01.000Z", requestId: "permission-1", + requestKind: "permission_approval", turnId: "provider-turn-1", requestType: "permission", status: "pending", + prompt: "Pi write", choices: [{ key: "decline", label: "Deny" }], fields: [] }, + { kind: "assistant", ts: "2026-08-25T18:00:03.000Z", text: "Steering acknowledged.", channel: "progress" }, + ]); + const resolve = vi.spyOn(heartbeatsApi, "resolveRuntimeRequest").mockResolvedValue({} as never); + render( {}} issueStatus="in_progress" activeRun={{ + id: runId, runtimeMode: "native", status: "running", invocationSource: "issue", triggerDetail: null, + startedAt: "2026-08-25T18:00:00.000Z", finishedAt: null, createdAt: "2026-08-25T18:00:00.000Z", + agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner", + }} />); + const deny = Array.from(container.querySelectorAll("button")).filter(button => button.textContent === "Deny"); + expect(deny).toHaveLength(1); + expect(deny[0].disabled).toBe(false); + expect(container.textContent).not.toContain("Cancelled"); + await act(async () => deny[0].click()); + expect(resolve).toHaveBeenCalledWith({ runId, requestId: "permission-1", turnId: "provider-turn-1", + requestKind: "permission_approval", resolution: { action: "decline" } }); + }); + it("labels the live tail as a continuation after the steering bubble", () => { nativeTranscriptState.transcriptByRun.set("native-live-steered", [ { diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index c11ae204e8..9902a3e035 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -55,6 +55,8 @@ import { settledRunChildren, splitTranscriptAtAnchors, transcriptToTaskChatItems, + runtimeRequestSegmentContext, + reconcileSegmentRuntimeRequests, type SettledTurnMergeMeta, } from "@/components/task-chat/transcript-adapter"; import { TaskChatDescriptionBubble } from "@/components/task-chat/TaskChatDescriptionBubble"; @@ -1868,12 +1870,15 @@ export function TaskChatThread(props: TaskChatThreadProps) { startSlotMs, timelineAnchors, ); + const runtimeRequests = runtimeRequestSegmentContext(entries, { + runId: source.id, agentName: meta?.agentName, running: false, + }); const projectedSegments = segments.map((segment) => { - const parsedTranscript = transcriptToTaskChatItems(segment.entries, { + const parsedTranscript = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems(segment.entries, { runId: source.id, agentName: meta?.agentName, running: false, - }); + }), segment.entries, runtimeRequests); const parsed = source.id === planDocumentSourceRunId && planTurnItem ? embedPlanDocumentAtWriteBoundary(parsedTranscript, planTurnItem) @@ -2016,13 +2021,16 @@ export function TaskChatThread(props: TaskChatThreadProps) { startSlotMs, timelineAnchors, ); + const runtimeRequests = runtimeRequestSegmentContext(entries, { + runId: liveRun.id, agentName: liveRun.agentName, running: true, + }); for (const [segmentIndex, segment] of segments.slice(0, -1).entries()) { if (segment.entries.length === 0) continue; - const parsedTranscript = transcriptToTaskChatItems(segment.entries, { + const parsedTranscript = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems(segment.entries, { runId: liveRun.id, agentName: liveRun.agentName, running: false, - }); + }), segment.entries, runtimeRequests); const parsed = liveRun.id === planDocumentSourceRunId && planTurnItem ? embedPlanDocumentAtWriteBoundary(parsedTranscript, planTurnItem) @@ -2251,7 +2259,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { // A fresh array is produced on every render. Track every presentation field // that can change without changing the entry count or text length so channel, // lifecycle, result status, and usage-only updates invalidate the projection. - const tailEntryContentKey = tailEntries.reduce((key, entry) => { + const tailEntryContentKey = tailAllEntries.reduce((key, entry) => { const textIdentity = "text" in entry ? entry.text : ""; const contentIdentity = "content" in entry ? entry.content : ""; const channelIdentity = "channel" in entry ? (entry.channel ?? "") : ""; @@ -2263,8 +2271,9 @@ export function TaskChatThread(props: TaskChatThreadProps) { entry.kind === "result" ? `${entry.subtype}:${entry.inputTokens}:${entry.outputTokens}:${entry.cachedTokens}:${entry.costUsd}` : ""; - return `${key}|${entry.kind}:${channelIdentity}:${lifecycleIdentity}:${statusIdentity}:${usageIdentity}:${textIdentity}:${contentIdentity}`; - }, String(tailEntries.length)); + const requestIdentity = entry.kind === "runtime_request" ? JSON.stringify(entry) : ""; + return `${key}|${entry.kind}:${channelIdentity}:${lifecycleIdentity}:${statusIdentity}:${usageIdentity}:${textIdentity}:${contentIdentity}:${requestIdentity}`; + }, String(tailAllEntries.length)); const tailContentKey = `${tailSegmentStartMs ?? "run-start"}:${tailEntryContentKey}`; const blockerContentKey = blockerLinks ? `${blockerLinks.directBlocker.id}:${blockerLinks.ultimateBlocker?.id ?? ""}` @@ -2341,11 +2350,14 @@ export function TaskChatThread(props: TaskChatThreadProps) { const tailItems = useMemo( () => { if (!tailRunId) return []; - const parsed = transcriptToTaskChatItems(tailEntries, { + const runtimeRequests = runtimeRequestSegmentContext(tailAllEntries, { + runId: tailRunId, agentName: tailAgentName, running: tailStreaming, + }); + const parsed = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems(tailEntries, { runId: tailRunId, agentName: tailAgentName, running: tailStreaming, - }); + }), tailEntries, runtimeRequests, tailStreaming); return tailPlanItem ? embedPlanDocumentAtWriteBoundary(parsed, tailPlanItem) : parsed; diff --git a/ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx b/ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx index f9e956e2c4..44414025d6 100644 --- a/ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx @@ -231,4 +231,22 @@ describe("TaskChatProtocolActivityRow", () => { expect(row?.querySelector("details")).toBeNull(); expect(row?.querySelector('[aria-expanded]')).toBeNull(); }); + it.each([ + ["info", "informational", "Provider update", "lucide-info"], + ["warning", "informational", "Warning", "lucide-triangle-alert"], + ["error", "failed", "Error", "lucide-triangle-alert"], + ["error", "informational", "Error", "lucide-triangle-alert"], + ["info", "failed", "Error", "lucide-triangle-alert"], + ] as const)("renders %s/%s notice severity without hiding its summary", (severity, status, label, icon) => { + const summary = "Pi estimates this turn at $0.000617. Billing cost is unverified."; + render({ + id: "notice", kind: "protocol", surface: "provider_activity", family: "provider_notice", + eventType: "provider.notice.recorded", status, title: "Provider notice", summary, + details: [{ label: "Severity", value: severity }], steps: [], links: [], children: [], + }); + expect(container.textContent).toContain(label); + expect(container.querySelector("p")?.textContent).toBe(summary); + expect(container.querySelector('[data-testid="task-chat-protocol-activity-icon"]')?.classList.contains(icon)).toBe(true); + }); + }); diff --git a/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx b/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx index 5440a4408e..b040c5253b 100644 --- a/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx @@ -1,6 +1,5 @@ import { useId, useState, type ReactNode } from "react"; import { - AlertTriangle, Check, ChevronRight, Circle, @@ -23,6 +22,7 @@ import { protocolActivityIsRunning, protocolActivityLabel, protocolActivityPresentation, + providerNoticeSeverity, } from "./task-chat-activity-presentation"; const COMPACT_RESEARCH_RESULT_LIMIT = 5; @@ -281,14 +281,16 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt const presentation = protocolActivityPresentation(item); if (!presentation) return null; if (item.surface === "provider_activity" && item.family === "provider_notice") { + const NoticeIcon = presentation.icon; + const severity = providerNoticeSeverity(item); const summary = item.summary ?? item.details.find((entry) => entry.label === "Summary")?.value ?? "The provider reported a notice without a message."; return (
- - {item.status === "failed" ? "Error" : item.details.find(detail => detail.label === "Severity")?.value === "info" ? "Provider update" : "Warning"} + + {severity === "error" ? "Error" : severity === "info" ? "Provider update" : "Warning"}

{summary}

{item.details.some(detail => detail.label !== "Summary") ? ( diff --git a/ui/src/components/task-chat/completed-activity-summary.test.ts b/ui/src/components/task-chat/completed-activity-summary.test.ts index 85c02dd034..b4493e85b8 100644 --- a/ui/src/components/task-chat/completed-activity-summary.test.ts +++ b/ui/src/components/task-chat/completed-activity-summary.test.ts @@ -1,3 +1,4 @@ +import { AlertTriangle, Info } from "lucide-react"; import { describe, expect, it } from "vitest"; import { completedActivitySummary } from "./completed-activity-summary"; import type { @@ -141,4 +142,48 @@ describe("completedActivitySummary", () => { completedActivitySummary([tool("mcp__github__get_pull_request")]).label, ).toBe("Used connected tools"); }); + it("shows the preserved pricing estimate as informational, not a billing receipt", () => { + const summary = "Pi estimates this turn at $0.000617 from its model prices. Billing cost is unverified."; + expect(completedActivitySummary([{ + ...provider("provider_notice", "informational"), summary, + details: [{ label: "Severity", value: "info" }], + }])).toEqual({ label: summary, fullLabel: summary, icon: Info }); + }); + it.each(["warning", "error", undefined])("retains warning/error icons and safe notice text (%s)", (severity) => { + const summary = "Provider request needs attention."; + const result = completedActivitySummary([{ + ...provider("provider_notice", severity === "error" ? "failed" : "informational"), + details: [ + { label: "Summary", value: summary }, + ...(severity ? [{ label: "Severity", value: severity }] : []), + ], + }]); + expect(result).toEqual({ label: summary, fullLabel: summary, icon: AlertTriangle }); + }); + it("uses a generic fallback without promoting arbitrary detail fields", () => { + expect(completedActivitySummary([{ + ...provider("provider_notice", "informational"), summary: " ", + details: [{ label: "Raw input", value: "private-command-or-path" }], + }]).label).toBe("Received a provider update"); + }); + it("does not hide a failed notice behind informational metadata", () => { + expect(completedActivitySummary([{ + ...provider("provider_notice", "failed"), summary: "Provider failed.", + details: [{ label: "Severity", value: "info" }], + }]).icon).toBe(AlertTriangle); + }); + + it("groups distinct notices and keeps a later error visible beside actual work", () => { + const notices = ["info", "info", "warning", "error"].map((severity, index) => ({ + ...provider("provider_notice", "informational"), + id: `notice-${index}`, + summary: `Distinct provider message ${index}`, + details: [{ label: "Severity", value: severity }], + })); + const result = completedActivitySummary([tool("bash"), ...notices]); + expect(result.label).toBe("Provider error reported, ran commands"); + expect(result.fullLabel).toBe(result.label); + expect(result.label).not.toContain("Distinct provider message"); + }); + }); diff --git a/ui/src/components/task-chat/completed-activity-summary.ts b/ui/src/components/task-chat/completed-activity-summary.ts index d21c786e66..776faabbfb 100644 --- a/ui/src/components/task-chat/completed-activity-summary.ts +++ b/ui/src/components/task-chat/completed-activity-summary.ts @@ -5,7 +5,7 @@ import { type ToolFamily, type ToolIcon, } from "./tool-taxonomy"; -import { protocolActivityPresentation } from "./task-chat-activity-presentation"; +import { protocolActivityPresentation, providerNoticeSeverity } from "./task-chat-activity-presentation"; type Activity = TaskChatActivityPhaseItem["items"][number]; const labels: Record = { @@ -42,6 +42,12 @@ export function completedActivitySummary(items: Activity[]) { completed: boolean; order: number; }> = []; + const notices: Array<{ + summary: string | undefined; + severity: "info" | "warning" | "error"; + icon: ToolIcon; + order: number; + }> = []; for (const [order, item] of items.entries()) { if (item.kind === "tool") { const p = toolActivityPresentation({ @@ -67,6 +73,13 @@ export function completedActivitySummary(items: Activity[]) { } else { const p = protocolActivityPresentation(item); if (!p) continue; + if (item.surface === "provider_activity" && item.family === "provider_notice") { + // Reuse only the normalized notice text already available in the row. + const summary = item.summary?.trim() + || item.details.find((entry) => entry.label === "Summary")?.value.trim(); + notices.push({ summary, severity: providerNoticeSeverity(item), icon: p.icon, order }); + continue; + } const family = item.surface === "provider_activity" ? item.family : item.surface; const label = @@ -84,7 +97,6 @@ export function completedActivitySummary(items: Activity[]) { safety: "Reviewed safety", terminal: "Ran commands", wait: "Waited", - provider_notice: "Received a provider update", workspace_change: "Worked on files", workspace_file: "Referenced files", resource: "Added resources", @@ -94,6 +106,21 @@ export function completedActivitySummary(items: Activity[]) { } } } + if (notices.length) { + const strongest = notices.find((notice) => notice.severity === "error") + ?? notices.find((notice) => notice.severity === "warning") + ?? notices[0]!; + const label = notices.length === 1 + ? strongest.summary || "Received a provider update" + : strongest.severity === "error" + ? "Provider error reported" + : strongest.severity === "warning" + ? "Provider warning reported" + : "Received provider updates"; + // One notice category leaves room for actual work. Keep warnings and errors + // visible when the compact label truncates other activity categories. + add(label, strongest.icon, strongest.severity === "info" ? notices[0]!.order : -1); + } const completedFamilies = new Set( tools.filter((tool) => tool.completed).map((tool) => tool.family), ); diff --git a/ui/src/components/task-chat/task-chat-activity-presentation.ts b/ui/src/components/task-chat/task-chat-activity-presentation.ts index 4d905b65ff..a0cb58d0d0 100644 --- a/ui/src/components/task-chat/task-chat-activity-presentation.ts +++ b/ui/src/components/task-chat/task-chat-activity-presentation.ts @@ -9,6 +9,7 @@ import { FilePenLine, FileText, GitBranch, + Info, ListChecks, PackageCheck, Search, @@ -40,6 +41,12 @@ export interface TaskChatActivityPresentation { detail?: string; } +export function providerNoticeSeverity(item: TaskChatProviderActivityItem): "info" | "warning" | "error" { + const severity = item.details.find((entry) => entry.label === "Severity")?.value; + if (item.status === "failed" || severity === "error") return "error"; + return severity === "info" ? "info" : "warning"; +} + function providerDetail(item: TaskChatProviderActivityItem, ...labels: string[]): string | undefined { return item.details.find((entry) => labels.includes(entry.label))?.value; } @@ -135,7 +142,7 @@ export function providerActivityPresentation(item: TaskChatProviderActivityItem) case "wait": return { icon: Clock3, runningLabel: "Waiting", completedLabel: "Finished waiting", failedLabel: "Wait failed", interruptedLabel: "Wait stopped", detail }; case "provider_notice": - return { icon: AlertTriangle, runningLabel: "Provider notice", completedLabel: "Provider notice", failedLabel: "Provider error", interruptedLabel: "Provider notice", detail }; + return { icon: providerNoticeSeverity(item) === "info" ? Info : AlertTriangle, runningLabel: "Provider notice", completedLabel: "Provider notice", failedLabel: "Provider error", interruptedLabel: "Provider notice", detail }; } } diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index 455055c04a..8740fc1fa4 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -24,6 +24,8 @@ import { splitTranscriptAtAnchors, toolDisplayName, transcriptToTaskChatItems, + runtimeRequestSegmentContext, + reconcileSegmentRuntimeRequests, type SettledTurnMergeMeta, type ThreadBackboneEntry, } from "./transcript-adapter"; @@ -32,11 +34,43 @@ import type { TaskChatPlanDocumentItem, TaskChatTurnItem, } from "./task-chat-model"; +import { latestPendingRuntimeRequest } from "./task-chat-model"; import { providerActivityPresentation } from "./task-chat-activity-presentation"; import { nativeRunEventsToTranscript } from "../transcript/native-run-events"; const TS = "2026-07-31T12:00:00.000Z"; +describe("runtime requests across steering sections", () => { + it("selects the newer request when an older pending permission crosses steering", () => { + const older: TranscriptEntry = { kind: "runtime_request", ts: TS, requestId: "older-permission", + requestKind: "permission_approval", turnId: "provider-turn", requestType: "permission", status: "pending", + prompt: "Earlier write", choices: [{ key: "decline", label: "Deny" }], fields: [] }; + const newer: TranscriptEntry = { ...older, ts: "2026-07-31T12:00:02.000Z", requestId: "newer-permission", prompt: "Later write" }; + const options = { runId: "run-steering", running: true }; + const context = runtimeRequestSegmentContext([older, newer], options); + const tail = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems([newer], options), [newer], context, true); + expect(tail.map(item => item.id)).toEqual(["run-steering:runtime-request:older-permission", "run-steering:runtime-request:newer-permission"]); + expect(latestPendingRuntimeRequest(tail)?.requestId).toBe("newer-permission"); + }); + + it.each(["pending", "resolved", "cancelled"] as const)("keeps one %s permission under whole-run authority", status => { + const request: TranscriptEntry = { kind: "runtime_request", ts: TS, requestId: "permission-1", + requestKind: "permission_approval", turnId: "provider-turn", requestType: "permission", status: "pending", + prompt: "Pi write", choices: [{ key: "decline", label: "Deny" }], fields: [] }; + const after: TranscriptEntry[] = [{ kind: "assistant", ts: "2026-07-31T12:00:01.000Z", text: "Continued.", channel: "progress" }]; + if (status === "resolved") after.push({ ...request, ts: "2026-07-31T12:00:02.000Z", status, resolvedAction: "decline" }); + const entries = [request, ...after]; + const options = { runId: "run-steering", running: status !== "cancelled" }; + const context = runtimeRequestSegmentContext(entries, options); + const history = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems([request], { ...options, running: false }), [request], context); + const tail = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems(after, options), after, context, options.running); + const cards = [...history, ...tail].filter(item => item.kind === "protocol" && item.surface === "runtime_request"); + expect(cards).toEqual([expect.objectContaining({ requestId: "permission-1", turnId: "provider-turn", status })]); + expect(history.filter(item => item.kind === "protocol")).toHaveLength(status === "pending" ? 0 : 1); + expect(tail.filter(item => item.kind === "protocol")).toHaveLength(status === "pending" ? 1 : 0); + }); +}); + describe("accepted native response-wake answers", () => { const runId = "native-response-wake"; const summary = diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index 5f84863768..82502c4859 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -20,6 +20,7 @@ import type { TaskChatRunResultItem, TaskChatMessageItem, TaskChatPlanDocumentItem, + TaskChatRuntimeRequestItem, } from "./task-chat-model"; import { humanizeToolName, @@ -570,6 +571,52 @@ interface TranscriptAdapterOptions { running: boolean; } +/** Request authority follows the whole run, even when steering splits its display. */ +export function runtimeRequestSegmentContext( + entries: readonly TranscriptEntry[], + options: TranscriptAdapterOptions, +) { + const requests = entries.filter(entry => entry.kind === "runtime_request"); + const origins = new Map(); + for (const entry of requests) { + if (!origins.has(entry.requestId)) origins.set(entry.requestId, entry); + } + const context = new Map(); + for (const item of transcriptToTaskChatItems(requests, options)) { + if (item.kind === "protocol" && item.surface === "runtime_request") { + context.set(item.id, { item, origin: origins.get(item.requestId)! }); + } + } + return context; +} + +/** Carry live requests to the tail; retain closed cards at their original position. */ +export function reconcileSegmentRuntimeRequests( + items: readonly TaskChatItem[], + entries: readonly TranscriptEntry[], + context: ReturnType, + carryPending = false, +): TaskChatItem[] { + const owned = new Set(entries); + const retained = new Set(); + const result = items.flatMap(item => { + if (item.kind !== "protocol" || item.surface !== "runtime_request") return [item]; + const state = context.get(item.id); + if (!state || (state.item.status === "pending" ? !carryPending : !owned.has(state.origin))) return []; + retained.add(item.id); + return [state.item]; + }); + const carried: TaskChatItem[] = []; + if (carryPending) { + for (const { item } of context.values()) { + if (item.status === "pending" && !retained.has(item.id)) carried.push(item); + } + } + // A carried request predates this section. Keep newer requests last so the + // composer selects the latest input instead of reviving an older one. + return [...carried, ...result]; +} + /** * Reduce a transcript into ordered items, coalescing consecutive thinking and * assistant deltas and threading tool_result/diff onto their tool_call. Mirrors diff --git a/ui/src/features/connections/ConnectionSetupHeader.tsx b/ui/src/features/connections/ConnectionSetupHeader.tsx index 0138cee499..4cac630abe 100644 --- a/ui/src/features/connections/ConnectionSetupHeader.tsx +++ b/ui/src/features/connections/ConnectionSetupHeader.tsx @@ -73,4 +73,3 @@ export function StepHeader({
); } - diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index b1d5aaddca..1b4137624d 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -770,7 +770,11 @@ describe("CompanyEnvironments — test provider button", () => { // bindings even when the environment has none yet. "company-1", ); - expect(getEnvironmentFormPage()).toBeNull(); + // Saving awaits query invalidation before navigating away. A single flush + // can observe the API call before that asynchronous success path settles. + await waitForAssertion(() => { + expect(getEnvironmentFormPage()).toBeNull(); + }); }); it("confirms before cancelling the edit page with unsaved environment variable drafts", async () => {